Feat: 增加 Tauri 相关框架,页面增加 Tab 效果
|
|
@ -29,10 +29,12 @@
|
||||||
"react-virtuoso": "^4.7.11",
|
"react-virtuoso": "^4.7.11",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.16"
|
"tailwindcss": "^4.1.16",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
|
|
||||||
147
pnpm-lock.yaml
|
|
@ -65,10 +65,16 @@ importers:
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.16
|
specifier: ^4.1.16
|
||||||
version: 4.1.16
|
version: 4.1.16
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.10
|
||||||
|
version: 5.0.10(@types/react@19.2.2)(react@19.2.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.36.0
|
specifier: ^9.36.0
|
||||||
version: 9.39.0
|
version: 9.39.0
|
||||||
|
'@tauri-apps/cli':
|
||||||
|
specifier: ^2.9.6
|
||||||
|
version: 2.9.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.9.2
|
version: 24.9.2
|
||||||
|
|
@ -1009,6 +1015,77 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7
|
vite: ^5.2.0 || ^6 || ^7
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.9.6':
|
||||||
|
resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.9.6':
|
||||||
|
resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
|
||||||
|
resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.9.6':
|
||||||
|
resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
|
||||||
|
resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
|
||||||
|
resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
|
||||||
|
resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.9.6':
|
||||||
|
resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
|
||||||
|
resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.9.6':
|
||||||
|
resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.9.6':
|
||||||
|
resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.9.6':
|
||||||
|
resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
|
|
@ -1869,6 +1946,24 @@ packages:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zustand@5.0.10:
|
||||||
|
resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
|
|
@ -2640,6 +2735,53 @@ snapshots:
|
||||||
tailwindcss: 4.1.16
|
tailwindcss: 4.1.16
|
||||||
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
|
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.9.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.9.6':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tauri-apps/cli-darwin-arm64': 2.9.6
|
||||||
|
'@tauri-apps/cli-darwin-x64': 2.9.6
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu': 2.9.6
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl': 2.9.6
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu': 2.9.6
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu': 2.9.6
|
||||||
|
'@tauri-apps/cli-linux-x64-musl': 2.9.6
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc': 2.9.6
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.28.5
|
'@babel/parser': 7.28.5
|
||||||
|
|
@ -3448,3 +3590,8 @@ snapshots:
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zustand@5.0.10(@types/react@19.2.2)(react@19.2.0):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
react: 19.2.0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
license = ""
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
log = "0.4"
|
||||||
|
tauri = { version = "2.9.5", features = [] }
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
|
@ -0,0 +1,16 @@
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.setup(|app| {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
app.handle().plugin(
|
||||||
|
tauri_plugin_log::Builder::default()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.build(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
app_lib::run();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"productName": "translate-it",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.tauri.dev",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:5007",
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"beforeBuildCommand": "pnpm build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Translate It",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useProjectTabsStore } from "@/store/project-tabs-store";
|
||||||
|
|
||||||
|
export function ProjectTabsBar() {
|
||||||
|
const tabs = useProjectTabsStore((state) => state.tabs);
|
||||||
|
const activeTabId = useProjectTabsStore((state) => state.activeTabId);
|
||||||
|
const activateTab = useProjectTabsStore((state) => state.activateTab);
|
||||||
|
const activateHome = useProjectTabsStore((state) => state.activateHome);
|
||||||
|
const closeTab = useProjectTabsStore((state) => state.closeTab);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const active = tabs.find((tab) => tab.id === activeTabId);
|
||||||
|
if (!active) return;
|
||||||
|
const targetPath = active.kind === "home" ? "/" : `/editor/${active.projectId}`;
|
||||||
|
if (location.pathname === targetPath) return;
|
||||||
|
navigate(targetPath);
|
||||||
|
}, [activeTabId, tabs, location.pathname, navigate]);
|
||||||
|
|
||||||
|
function handleSelect(tabId: string, kind: "home" | "project") {
|
||||||
|
if (kind === "home") activateHome();
|
||||||
|
else activateTab(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(tabId: string) {
|
||||||
|
closeTab(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-muted px-2 pt-2">
|
||||||
|
<div className="flex h-8 items-stretch gap-1 overflow-x-auto">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.id === activeTabId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"group relative flex min-w-[140px] max-w-[200px] shrink-0 items-center gap-2 rounded-t-md border px-3 text-sm transition border-b-0",
|
||||||
|
isActive
|
||||||
|
? "border-border bg-background text-foreground z-1"
|
||||||
|
: "border-transparent bg-transparent text-muted-foreground hover:border-border/60 hover:bg-background/80 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
title={tab.label}
|
||||||
|
onClick={() => handleSelect(tab.id, tab.kind)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
{tab.closable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto rounded-sm text-muted-foreground/80 transition hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClose(tab.id);
|
||||||
|
}}
|
||||||
|
aria-label="关闭标签"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="block absolute bottom-0 left-0 right-0 h-2 border-b border-border/70"></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,8 @@ import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-c
|
||||||
import { generateLanguageJson } from "@/lib/utils";
|
import { generateLanguageJson } from "@/lib/utils";
|
||||||
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
|
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
|
||||||
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
|
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
|
||||||
|
import { useProjectTabsStore } from "@/store/project-tabs-store";
|
||||||
|
import { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const { id: projectId } = useParams();
|
const { id: projectId } = useParams();
|
||||||
|
|
@ -71,6 +73,10 @@ export default function Editor() {
|
||||||
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
const connSnap = useFileConnections(projectId ?? "");
|
const connSnap = useFileConnections(projectId ?? "");
|
||||||
|
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
|
||||||
|
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
|
||||||
|
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
|
||||||
|
const activateHomeTab = useProjectTabsStore((state) => state.activateHome);
|
||||||
|
|
||||||
function highlightRow(index: number) {
|
function highlightRow(index: number) {
|
||||||
const tryFindAndAnimate = (attempt = 0) => {
|
const tryFindAndAnimate = (attempt = 0) => {
|
||||||
|
|
@ -136,6 +142,7 @@ export default function Editor() {
|
||||||
const p = await getProject(projectId);
|
const p = await getProject(projectId);
|
||||||
if (!p) throw new Error("项目不存在");
|
if (!p) throw new Error("项目不存在");
|
||||||
setProject(p);
|
setProject(p);
|
||||||
|
upsertProjectMeta(p);
|
||||||
const s = await getStructure(projectId);
|
const s = await getStructure(projectId);
|
||||||
if (s) setStructure(s);
|
if (s) setStructure(s);
|
||||||
const langs = await listLanguages(projectId);
|
const langs = await listLanguages(projectId);
|
||||||
|
|
@ -147,6 +154,11 @@ export default function Editor() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
openProjectTab({ id: projectId });
|
||||||
|
}, [projectId, openProjectTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -458,16 +470,11 @@ export default function Editor() {
|
||||||
}, [projectId, languages]);
|
}, [projectId, languages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col">
|
<div className="flex h-screen flex-col bg-background">
|
||||||
<header className="h-14 px-2 md:px-4 from-blue-400 to-cyan-400 bg-linear-to-r text-white">
|
<ProjectTabsBar />
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="h-14 bg-linear-to-r from-blue-400 to-cyan-400 px-2 text-white md:px-4">
|
||||||
<div className="grid grid-cols-3 h-full items-center">
|
<div className="grid grid-cols-3 h-full items-center">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button asChild size="icon" variant="ghost" aria-label="返回">
|
|
||||||
<Link to="/">
|
|
||||||
<ArrowLeft className="size-5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="text-center font-medium truncate">
|
<div className="text-center font-medium truncate">
|
||||||
{project?.name ?? "编辑器"}
|
{project?.name ?? "编辑器"}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -653,6 +660,7 @@ export default function Editor() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ImportLanguageModal
|
<ImportLanguageModal
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
|
|
@ -679,10 +687,13 @@ export default function Editor() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
|
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
|
||||||
setProject(next);
|
setProject(next);
|
||||||
|
upsertProjectMeta(next);
|
||||||
}}
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
await deleteProjectDeep(projectId);
|
await deleteProjectDeep(projectId);
|
||||||
|
forgetProjectInTabs(projectId);
|
||||||
|
activateHomeTab();
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import { useConnectionFlags } from "@/store/file-connection";
|
import { useConnectionFlags } from "@/store/file-connection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useProjectTabsStore } from "@/store/project-tabs-store";
|
||||||
|
import { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
|
||||||
|
|
||||||
function formatTime(ts: number) {
|
function formatTime(ts: number) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -26,6 +28,11 @@ function App() {
|
||||||
const [currentProject, setCurrentProject] = useState<Project | null>(null);
|
const [currentProject, setCurrentProject] = useState<Project | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const flags = useConnectionFlags();
|
const flags = useConnectionFlags();
|
||||||
|
const activateHomeTab = useProjectTabsStore((state) => state.activateHome);
|
||||||
|
const registerProjectsInTabs = useProjectTabsStore((state) => state.registerProjects);
|
||||||
|
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
|
||||||
|
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
|
||||||
|
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -43,6 +50,15 @@ function App() {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activateHomeTab();
|
||||||
|
}, [activateHomeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projects.length === 0) return;
|
||||||
|
registerProjectsInTabs(projects);
|
||||||
|
}, [projects, registerProjectsInTabs]);
|
||||||
|
|
||||||
const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]);
|
const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]);
|
||||||
|
|
||||||
async function onCreate(e: React.FormEvent) {
|
async function onCreate(e: React.FormEvent) {
|
||||||
|
|
@ -62,6 +78,9 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
|
<ProjectTabsBar />
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||||
<h1 className="text-2xl font-semibold">翻译它!</h1>
|
<h1 className="text-2xl font-semibold">翻译它!</h1>
|
||||||
|
|
||||||
|
|
@ -90,25 +109,31 @@ function App() {
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
|
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{projects.map((p) => {
|
{projects.map((p) => {
|
||||||
const hasConn = !!flags[p.id];
|
const hasConn = !!flags[p.id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
|
className="relative cursor-pointer rounded-md border p-4 transition hover:shadow-sm"
|
||||||
onClick={() => navigate(`/editor/${p.id}`)}
|
onClick={() => {
|
||||||
|
openProjectTab(p);
|
||||||
|
navigate(`/editor/${p.id}`);
|
||||||
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
openProjectTab(p);
|
||||||
|
navigate(`/editor/${p.id}`);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="项目设置"
|
aria-label="项目设置"
|
||||||
className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
className="absolute right-2 top-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setCurrentProject(p);
|
setCurrentProject(p);
|
||||||
|
|
@ -117,7 +142,7 @@ function App() {
|
||||||
>
|
>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="truncate font-medium pr-8 flex items-center gap-2">
|
<div className="flex items-center gap-2 truncate pr-8 font-medium">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block size-2 rounded-full",
|
"inline-block size-2 rounded-full",
|
||||||
|
|
@ -127,10 +152,10 @@ function App() {
|
||||||
/>
|
/>
|
||||||
{p.name}
|
{p.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div>
|
<div className="mt-1 truncate text-xs text-muted-foreground">ID: {p.id}</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">创建时间: {formatTime(p.createdAt)}</div>
|
<div className="mt-1 text-xs text-muted-foreground">创建时间: {formatTime(p.createdAt)}</div>
|
||||||
{p.preferences?.aiPrompt ? (
|
{p.preferences?.aiPrompt ? (
|
||||||
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground">
|
<div className="mt-3 line-clamp-3 text-xs text-muted-foreground">
|
||||||
偏好:{p.preferences.aiPrompt}
|
偏好:{p.preferences.aiPrompt}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -150,16 +175,20 @@ function App() {
|
||||||
const next = await updateProject({ id: currentProject.id, name: update.name, preferences: update.preferences });
|
const next = await updateProject({ id: currentProject.id, name: update.name, preferences: update.preferences });
|
||||||
setProjects((arr) => arr.map((it) => (it.id === next.id ? next : it)));
|
setProjects((arr) => arr.map((it) => (it.id === next.id ? next : it)));
|
||||||
setCurrentProject(next);
|
setCurrentProject(next);
|
||||||
|
upsertProjectMeta(next);
|
||||||
}}
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
if (!currentProject) return;
|
if (!currentProject) return;
|
||||||
await deleteProjectDeep(currentProject.id);
|
await deleteProjectDeep(currentProject.id);
|
||||||
|
forgetProjectInTabs(currentProject.id);
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
setCurrentProject(null);
|
setCurrentProject(null);
|
||||||
await refresh();
|
await refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import type { Project } from "@/lib/db";
|
||||||
|
|
||||||
|
export type ProjectMeta = { id: string } & Partial<Omit<Project, "id">>;
|
||||||
|
|
||||||
|
type BaseTab = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
closable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HomeTab = BaseTab & { kind: "home" };
|
||||||
|
type ProjectTab = BaseTab & { kind: "project"; projectId: string };
|
||||||
|
export type ProjectTabDescriptor = HomeTab | ProjectTab;
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
tabs: ProjectTabDescriptor[];
|
||||||
|
activeTabId: string;
|
||||||
|
projectIndex: Record<string, ProjectMeta>;
|
||||||
|
lastActiveProjectId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Actions = {
|
||||||
|
activateHome: () => void;
|
||||||
|
activateTab: (tabId: string) => void;
|
||||||
|
openProjectTab: (meta: ProjectMeta | string, opts?: { activate?: boolean }) => string;
|
||||||
|
closeTab: (tabId: string) => void;
|
||||||
|
closeProjectTab: (projectId: string) => void;
|
||||||
|
registerProjects: (projects: ProjectMeta[]) => void;
|
||||||
|
upsertProjectMeta: (project: ProjectMeta) => void;
|
||||||
|
forgetProject: (projectId: string) => void;
|
||||||
|
getActiveProjectId: () => string | null;
|
||||||
|
isProjectOpen: (projectId: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTabsStore = State & Actions;
|
||||||
|
|
||||||
|
export const HOME_TAB_ID = "tab-home";
|
||||||
|
const HOME_TAB: HomeTab = { id: HOME_TAB_ID, kind: "home", label: "首页", closable: false };
|
||||||
|
|
||||||
|
function buildProjectTabId(projectId: string): string {
|
||||||
|
return `tab-${projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectMeta(meta: ProjectMeta | string): ProjectMeta {
|
||||||
|
if (typeof meta === "string") return { id: meta };
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveProjectLabel(projectId: string, meta?: ProjectMeta): string {
|
||||||
|
if (meta?.name?.trim()) return meta.name.trim();
|
||||||
|
if (projectId.length <= 8) return projectId;
|
||||||
|
return `项目 ${projectId.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreferencesEqual(a?: Project["preferences"], b?: Project["preferences"]): boolean {
|
||||||
|
if (!a && !b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.aiModel === b.aiModel && a.aiPrompt === b.aiPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePreferences(prev?: Project["preferences"], incoming?: Project["preferences"]): Project["preferences"] | undefined {
|
||||||
|
if (!prev && !incoming) return undefined;
|
||||||
|
if (!prev) return incoming ? { ...incoming } : undefined;
|
||||||
|
if (!incoming) return prev;
|
||||||
|
const next = { ...prev, ...incoming };
|
||||||
|
return isPreferencesEqual(prev, next) ? prev : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMetaEqual(a: ProjectMeta, b: ProjectMeta): boolean {
|
||||||
|
return (
|
||||||
|
a.name === b.name &&
|
||||||
|
a.createdAt === b.createdAt &&
|
||||||
|
a.updatedAt === b.updatedAt &&
|
||||||
|
isPreferencesEqual(a.preferences, b.preferences)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProjectMeta(prev: ProjectMeta | undefined, incoming: ProjectMeta): { meta: ProjectMeta; changed: boolean } {
|
||||||
|
if (!prev) {
|
||||||
|
return { meta: { ...incoming }, changed: true };
|
||||||
|
}
|
||||||
|
const mergedPrefs = mergePreferences(prev.preferences, incoming.preferences);
|
||||||
|
const next: ProjectMeta = {
|
||||||
|
...prev,
|
||||||
|
...incoming,
|
||||||
|
...(mergedPrefs ? { preferences: mergedPrefs } : {}),
|
||||||
|
};
|
||||||
|
if (isMetaEqual(prev, next)) {
|
||||||
|
return { meta: prev, changed: false };
|
||||||
|
}
|
||||||
|
return { meta: next, changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureProjectMeta(index: Record<string, ProjectMeta>, incoming: ProjectMeta): { index: Record<string, ProjectMeta>; meta: ProjectMeta } {
|
||||||
|
const current = index[incoming.id];
|
||||||
|
const { meta, changed } = mergeProjectMeta(current, incoming);
|
||||||
|
if (!changed) return { index, meta };
|
||||||
|
return { index: { ...index, [incoming.id]: meta }, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProjectTabsStore = create<ProjectTabsStore>((set, get) => ({
|
||||||
|
tabs: [HOME_TAB],
|
||||||
|
activeTabId: HOME_TAB_ID,
|
||||||
|
projectIndex: {},
|
||||||
|
lastActiveProjectId: null,
|
||||||
|
activateHome: () => {
|
||||||
|
set(() => ({
|
||||||
|
activeTabId: HOME_TAB_ID,
|
||||||
|
lastActiveProjectId: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
activateTab: (tabId) => {
|
||||||
|
set((state) => {
|
||||||
|
const target = state.tabs.find((tab) => tab.id === tabId);
|
||||||
|
if (!target) return state;
|
||||||
|
return {
|
||||||
|
activeTabId: tabId,
|
||||||
|
lastActiveProjectId: target.kind === "project" ? target.projectId : state.lastActiveProjectId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openProjectTab: (input, opts) => {
|
||||||
|
const normalized = normalizeProjectMeta(input);
|
||||||
|
if (!normalized.id) return HOME_TAB_ID;
|
||||||
|
const tabId = buildProjectTabId(normalized.id);
|
||||||
|
set((state) => {
|
||||||
|
const { index: projectIndex, meta } = ensureProjectMeta(state.projectIndex, normalized);
|
||||||
|
const label = deriveProjectLabel(meta.id, projectIndex[meta.id]);
|
||||||
|
const existing = state.tabs.find((tab) => tab.id === tabId);
|
||||||
|
const shouldActivate = opts?.activate !== false;
|
||||||
|
if (existing) {
|
||||||
|
const updated = existing.label === label ? existing : { ...existing, label };
|
||||||
|
return {
|
||||||
|
projectIndex,
|
||||||
|
tabs: state.tabs.map((tab) => (tab.id === tabId ? updated : tab)),
|
||||||
|
activeTabId: shouldActivate ? tabId : state.activeTabId,
|
||||||
|
lastActiveProjectId: shouldActivate ? meta.id : state.lastActiveProjectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nextTab: ProjectTab = { id: tabId, kind: "project", projectId: meta.id, label, closable: true };
|
||||||
|
return {
|
||||||
|
projectIndex,
|
||||||
|
tabs: [...state.tabs, nextTab],
|
||||||
|
activeTabId: shouldActivate ? tabId : state.activeTabId,
|
||||||
|
lastActiveProjectId: shouldActivate ? meta.id : state.lastActiveProjectId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return tabId;
|
||||||
|
},
|
||||||
|
closeTab: (tabId) => {
|
||||||
|
if (tabId === HOME_TAB_ID) return;
|
||||||
|
set((state) => {
|
||||||
|
const targetIndex = state.tabs.findIndex((tab) => tab.id === tabId);
|
||||||
|
if (targetIndex === -1) return state;
|
||||||
|
const target = state.tabs[targetIndex];
|
||||||
|
if (target.kind === "home") return state;
|
||||||
|
const nextTabs = state.tabs.filter((tab) => tab.id !== tabId);
|
||||||
|
const hasHome = nextTabs.some((tab) => tab.id === HOME_TAB_ID);
|
||||||
|
const tabs = hasHome ? nextTabs : [HOME_TAB, ...nextTabs];
|
||||||
|
const nextLastActive = state.lastActiveProjectId === target.projectId ? null : state.lastActiveProjectId;
|
||||||
|
if (state.activeTabId !== tabId) {
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
lastActiveProjectId: nextLastActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fallback = state.tabs[targetIndex - 1] ?? state.tabs[targetIndex + 1] ?? HOME_TAB;
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTabId: fallback.id,
|
||||||
|
lastActiveProjectId: fallback.kind === "project" ? fallback.projectId : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeProjectTab: (projectId) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
get().closeTab(buildProjectTabId(projectId));
|
||||||
|
},
|
||||||
|
registerProjects: (projects) => {
|
||||||
|
if (!projects || projects.length === 0) return;
|
||||||
|
set((state) => {
|
||||||
|
let nextIndex = state.projectIndex;
|
||||||
|
let indexChanged = false;
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project?.id) continue;
|
||||||
|
const result = ensureProjectMeta(nextIndex, project);
|
||||||
|
if (result.index !== nextIndex) {
|
||||||
|
nextIndex = result.index;
|
||||||
|
indexChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!indexChanged) return state;
|
||||||
|
let tabsChanged = false;
|
||||||
|
const tabs = state.tabs.map((tab) => {
|
||||||
|
if (tab.kind !== "project") return tab;
|
||||||
|
const label = deriveProjectLabel(tab.projectId, nextIndex[tab.projectId]);
|
||||||
|
if (label === tab.label) return tab;
|
||||||
|
tabsChanged = true;
|
||||||
|
return { ...tab, label };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
projectIndex: nextIndex,
|
||||||
|
tabs: tabsChanged ? tabs : state.tabs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
upsertProjectMeta: (project) => {
|
||||||
|
if (!project?.id) return;
|
||||||
|
set((state) => {
|
||||||
|
const result = ensureProjectMeta(state.projectIndex, project);
|
||||||
|
if (result.index === state.projectIndex) return state;
|
||||||
|
const tabs = state.tabs.map((tab) => {
|
||||||
|
if (tab.kind !== "project" || tab.projectId !== project.id) return tab;
|
||||||
|
const label = deriveProjectLabel(project.id, result.meta);
|
||||||
|
if (label === tab.label) return tab;
|
||||||
|
return { ...tab, label };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
projectIndex: result.index,
|
||||||
|
tabs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
forgetProject: (projectId) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
get().closeProjectTab(projectId);
|
||||||
|
set((state) => {
|
||||||
|
if (!state.projectIndex[projectId]) return state;
|
||||||
|
const { [projectId]: _removed, ...rest } = state.projectIndex;
|
||||||
|
return {
|
||||||
|
projectIndex: rest,
|
||||||
|
lastActiveProjectId: state.lastActiveProjectId === projectId ? null : state.lastActiveProjectId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getActiveProjectId: () => {
|
||||||
|
const { tabs, activeTabId } = get();
|
||||||
|
const active = tabs.find((tab) => tab.id === activeTabId);
|
||||||
|
return active && active.kind === "project" ? active.projectId : null;
|
||||||
|
},
|
||||||
|
isProjectOpen: (projectId) => {
|
||||||
|
if (!projectId) return false;
|
||||||
|
const tabId = buildProjectTabId(projectId);
|
||||||
|
return get().tabs.some((tab) => tab.id === tabId);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -6,6 +6,9 @@ import react from "@vitejs/plugin-react";
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5007,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
|
|
||||||