diff --git a/crates/agent-gateway/internal/protocol/pbws/guard.go b/crates/agent-gateway/internal/protocol/pbws/guard.go index 3b3ef86a6..27b5b52c3 100644 --- a/crates/agent-gateway/internal/protocol/pbws/guard.go +++ b/crates/agent-gateway/internal/protocol/pbws/guard.go @@ -141,7 +141,7 @@ func vetChatFileOpen(req *gatewayv2.ChatFileOpenRequest) error { // enable_web_git 门控,读操作(status/log/diff 等)始终放行。 func gitActionIsWrite(action string) bool { switch action { - case "clone", "clone_start", "clone_cancel", "clone_dismiss", "init", "switch_branch", "create_branch", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "stash_push", "stash_pop": + case "clone", "clone_start", "clone_cancel", "clone_dismiss", "init", "switch_branch", "create_branch", "create_worktree", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "remove_worktree", "stash_push", "stash_pop": return true default: return false diff --git a/crates/agent-gateway/test/websocket/v2_git_gating_test.go b/crates/agent-gateway/test/websocket/v2_git_gating_test.go index 87e548cc7..afe619577 100644 --- a/crates/agent-gateway/test/websocket/v2_git_gating_test.go +++ b/crates/agent-gateway/test/websocket/v2_git_gating_test.go @@ -62,7 +62,7 @@ func TestV2GitRejectsWriteRequestsWhenDisabled(t *testing.T) { _, _, conn, cleanup := newV2GitBrowserTest(t, false) defer cleanup() - for _, action := range []string{"clone", "stage", "init", "stage_all", "unstage_all", "discard_all", "push", "commit"} { + for _, action := range []string{"clone", "stage", "init", "create_worktree", "remove_worktree", "stage_all", "unstage_all", "discard_all", "push", "commit"} { id := "git-disabled-" + action sendGitAgentRequest(t, conn, id, action) @@ -99,10 +99,12 @@ func TestV2GitAllowsWriteRequestsWhenEnabled(t *testing.T) { _, agentSession, conn, cleanup := newV2GitBrowserTest(t, true) defer cleanup() - sendGitAgentRequest(t, conn, "git-stage-1", "stage") + for _, action := range []string{"stage", "create_worktree", "remove_worktree"} { + sendGitAgentRequest(t, conn, "git-write-"+action, action) - outbound := readOutboundEnvelope(t, agentSession) - if outbound.GetGitRequest().GetAction() != "stage" { - t.Fatalf("outbound = %#v, want forwarded git stage request", outbound) + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetGitRequest().GetAction() != action { + t.Fatalf("outbound = %#v, want forwarded git %s request", outbound, action) + } } } diff --git a/crates/agent-gateway/test/webui/gateway-git-client.test.mjs b/crates/agent-gateway/test/webui/gateway-git-client.test.mjs new file mode 100644 index 000000000..3d55c27fc --- /dev/null +++ b/crates/agent-gateway/test/webui/gateway-git-client.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { createGatewayGitClient } = loader.loadModule("src/lib/git/gatewayGitClient.ts"); + +test("gateway git client forwards worktree create and remove operations", async () => { + const calls = []; + const api = { + async gitRequest(action, workdir, args) { + calls.push({ action, workdir, args }); + if (action === "create_worktree") { + return { ok: true, worktreePath: "/workspace/.worktrees/topic" }; + } + return { ok: true, worktreeRemoved: true }; + }, + }; + const client = createGatewayGitClient(api); + + const created = await client.createWorktree("/workspace/project", { + branch: "topic", + directoryName: "topic-dir", + parentDirectory: "/workspace/worktrees", + startPoint: "main", + }); + const removed = await client.removeWorktree("/workspace/project", "/workspace/.worktrees/topic", { + force: true, + deleteBranch: true, + }); + + assert.equal(created.worktreePath, "/workspace/.worktrees/topic"); + assert.equal(removed.worktreeRemoved, true); + assert.deepEqual(calls, [ + { + action: "create_worktree", + workdir: "/workspace/project", + args: { + branch: "topic", + directoryName: "topic-dir", + parentDirectory: "/workspace/worktrees", + startPoint: "main", + }, + }, + { + action: "remove_worktree", + workdir: "/workspace/project", + args: { + worktreePath: "/workspace/.worktrees/topic", + force: true, + deleteBranch: true, + }, + }, + ]); +}); diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index b4f03d411..817974544 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -10,6 +10,43 @@ const chatHelpers = loader.loadModule("@/lib/chat/chatPageHelpers.ts"); const adminApi = loader.loadModule("@/lib/adminApi.ts"); const RIGHT_DOCK_TAB_IDS = settings.RIGHT_DOCK_SINGLETON_TAB_IDS; +test("web settings normalize and preserve workspace project groups", () => { + const normalized = settings.normalizeSettings({ + system: { + workspaceProjectGroups: [ + { + id: " source-group ", + name: " Source ", + projectPaths: [" /workspace/project ", "/workspace/project", "/workspace/topic"], + sourceProjectPath: " /workspace/project ", + collapsed: true, + createdAt: 100, + updatedAt: 200, + }, + { id: "source-group", name: "duplicate", projectPaths: [] }, + ], + }, + }); + + assert.deepEqual(normalized.system.workspaceProjectGroups, [ + { + id: "source-group", + name: "Source", + projectPaths: ["/workspace/project", "/workspace/topic"], + sourceProjectPath: "/workspace/project", + collapsed: true, + createdAt: 100, + updatedAt: 200, + }, + ]); + + const update = settingsSync.buildGatewaySettingsSyncUpdatePayload( + settings.normalizeSettings({}), + normalized, + ); + assert.deepEqual(update.system.workspaceProjectGroups, normalized.system.workspaceProjectGroups); +}); + test("custom provider normalization defaults and filters ordered custom headers", () => { assert.deepEqual(settings.normalizeCustomProvider({}).customHeaders, []); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 7f0613111..5e375ec4f 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -1,5 +1,6 @@ import { ApplicationView } from "@liveagent/ui/application/ApplicationView"; import { AppErrorBoundary } from "@liveagent/ui/components/AppErrorBoundary"; +import type { WorkspaceProjectRemoveOptions } from "@liveagent/ui/components/chat/ChatHistorySidebar"; import { FileDropOverlay } from "@liveagent/ui/components/chat/FileDropOverlay"; import type { MentionComposerDraft, @@ -31,7 +32,6 @@ import { readToolApprovalPending, readToolApprovalSummary, } from "@liveagent/ui/lib/chat/toolApprovalArgs"; -import { memoryDeleteProject } from "@liveagent/ui/lib/memory/api"; import { createUuid } from "@liveagent/ui/lib/shared/id"; import { mergeAlwaysEnabledSkillNames } from "@liveagent/ui/lib/skills/index"; import { useChatSkills } from "@liveagent/ui/lib/skills/useChatSkills"; @@ -179,9 +179,13 @@ import { sortSidebarConversations } from "@liveagent/ui/lib/sidebar/reconcile"; import { createSidebarStore } from "@liveagent/ui/lib/sidebar/store"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + fallbackWorkspaceProjectName, findWorkspaceProject, mergeWorkspaceProjectsWithHistory, } from "@liveagent/ui/lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { FloorNavRail } from "@liveagent/ui/pages/chat/transcript/FloorNavRail"; import { CHAT_TRANSCRIPT_WIDTH_CSS_VAR, @@ -223,7 +227,6 @@ import { MAX_UPLOAD_FILES, MCP_HUB_BROWSER_TITLE, NEW_CONVERSATION_BROWSER_TITLE, - PROJECT_HISTORY_DELETE_PAGE_SIZE, PROTECTED_DRAFT_CONVERSATION, SHARED_HISTORY_BROWSER_TITLE, SHARED_HISTORY_LIST_PAGE_SIZE, @@ -1241,18 +1244,23 @@ export default function GatewayApp() { const pathKey = project.path.trim(); if (!pathKey) return; const normalizedPathKey = workspaceProjectPathKey(pathKey); - const targetProject = - workspaceProjects.find( - (item) => - workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, - ) ?? project; + const matchedProject = workspaceProjects.find( + (item) => + workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, + ); + const targetProject = matchedProject + ? { + ...matchedProject, + ...(project.worktree ? { worktree: project.worktree } : {}), + } + : project; setActiveWorkspaceProjectId(targetProject.id); setSettings((prev) => { const existing = prev.system.workspaceProjects.find( (item) => workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, ); - const nextProject = existing ?? targetProject; + const nextProject = existing ? { ...targetProject, id: existing.id } : targetProject; const workspaceProjects = existing ? prev.system.workspaceProjects.map((item) => item.id === existing.id @@ -1266,6 +1274,7 @@ export default function GatewayApp() { : nextProject.kind === "history" ? item.kind : nextProject.kind, + worktree: nextProject.worktree ?? item.worktree, updatedAt: item.updatedAt, lastConversationAt: Math.max(item.lastConversationAt ?? 0, nextProject.lastConversationAt ?? 0) || @@ -1469,6 +1478,133 @@ export default function GatewayApp() { [activateWorkspaceProject, sidebarStore], ); + const handleOpenWorktree = useCallback( + (worktree: { path: string; repositoryPath: string; branch: string }) => { + const path = worktree.path.trim(); + const repositoryPath = worktree.repositoryPath.trim(); + const worktreeKey = workspaceProjectPathKey(path); + const currentProjectPath = displayedConversationWorkdirRef.current.trim(); + if (!path || !repositoryPath || !worktreeKey) return; + const branch = worktree.branch.trim(); + const nextProject: WorkspaceProject = { + ...createWorkspaceProjectFromPath(path, "managed"), + worktree: { + repositoryPath, + ...(branch ? { branch } : {}), + }, + }; + activateWorkspaceProject(nextProject); + setSettings((prev) => { + const sourceProject = prev.system.workspaceProjects.find( + (project) => + workspaceProjectPathKey(project.path) === workspaceProjectPathKey(repositoryPath), + ); + const ensured = ensureWorktreeProjectGroup(prev.system.workspaceProjectGroups, { + name: sourceProject?.name || fallbackWorkspaceProjectName(repositoryPath), + sourceProjectPath: repositoryPath, + }); + let workspaceProjectGroups = assignWorkspaceProjectToGroup( + ensured.groups, + ensured.groupId, + repositoryPath, + ); + if (currentProjectPath) { + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + currentProjectPath, + ); + } + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + path, + ); + return { ...prev, system: { ...prev.system, workspaceProjectGroups } }; + }); + void sidebarStore.refreshWorkdirs("new-workdir"); + }, + [activateWorkspaceProject, setSettings, sidebarStore], + ); + + const updateWorkspaceProjectGroups = useCallback( + (updater: (groups: WorkspaceProjectGroup[]) => WorkspaceProjectGroup[]) => { + setSettings((prev) => { + const next = updater(prev.system.workspaceProjectGroups); + if (next === prev.system.workspaceProjectGroups) return prev; + return { ...prev, system: { ...prev.system, workspaceProjectGroups: next } }; + }); + }, + [setSettings], + ); + + const handleCreateWorkspaceGroup = useCallback( + (nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + const now = Date.now(); + updateWorkspaceProjectGroups((groups) => [ + ...groups, + { id: createUuid(), name, projectPaths: [], createdAt: now, updatedAt: now }, + ]); + }, + [updateWorkspaceProjectGroups], + ); + + const handleRenameWorkspaceGroup = useCallback( + (groupId: string, nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId ? { ...group, name, updatedAt: Date.now() } : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + + const handleDeleteWorkspaceGroup = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => groups.filter((group) => group.id !== groupId)); + }, + [updateWorkspaceProjectGroups], + ); + + const handleMoveWorkspaceProjectToGroup = useCallback( + (projectPath: string, groupId: string | null) => { + const pathKey = workspaceProjectPathKey(projectPath); + if (!pathKey) return; + updateWorkspaceProjectGroups((groups) => { + if (groupId === null) { + return groups.map((group) => { + const projectPaths = group.projectPaths.filter( + (path) => workspaceProjectPathKey(path) !== pathKey, + ); + return projectPaths.length === group.projectPaths.length + ? group + : { ...group, projectPaths, updatedAt: Date.now() }; + }); + } + return assignWorkspaceProjectToGroup(groups, groupId, projectPath); + }); + }, + [updateWorkspaceProjectGroups], + ); + + const handleToggleWorkspaceGroupCollapsed = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId + ? { ...group, collapsed: !group.collapsed, updatedAt: Date.now() } + : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + const commitWorkspaceProjectRename = useCallback( (project: WorkspaceProject, nextNameInput: string) => { if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; @@ -2947,76 +3083,72 @@ export default function GatewayApp() { [archivedWorkspaceProjectPathKeys, setSettings, workspaceProjects], ); + // 分支选择器里删除 worktree 成功后,同步清理对应的工作空间登记 + // (与 GUI 端 ChatPage.handleWorktreeRemoved 保持镜像)。 + const handleWorktreeRemoved = useCallback( + (worktree: { path: string }) => { + const pathKey = workspaceProjectPathKey(worktree.path); + if (!pathKey) return; + const project = workspaceProjects.find( + (item) => workspaceProjectPathKey(item.path) === pathKey, + ); + if (project) removeWorkspaceProjectFromSettings(project); + }, + [removeWorkspaceProjectFromSettings, workspaceProjects], + ); + const handleRemoveWorkspaceProject = useCallback( - (project: WorkspaceProject) => { + (project: WorkspaceProject, options: WorkspaceProjectRemoveOptions = {}) => { if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; - void (async () => { - const currentApi = api; - if (!currentApi) { - setSidebarActionError("Gateway 未连接,暂时不能删除项目会话。"); - return; + const path = project.path.trim(); + const pathKey = workspaceProjectPathKey(path); + const projectHasRunningConversation = () => { + if (!pathKey) return false; + if (sidebarStore.getSnapshot().runningWorkdirPathKeys.has(pathKey)) { + return true; } - - const path = project.path.trim(); - const pathKey = workspaceProjectPathKey(path); - const runningMessage = "项目中仍有后台任务运行,暂时不能删除该项目。"; - const projectHasRunningConversation = () => { - if (!pathKey) return false; - for (const [conversationId, activity] of activityStore.getSnapshot().activities) { - const runtimeWorkdir = - activity.workdir?.trim() || - conversationWorkdirsRef.current.get(conversationId)?.trim() || - ""; - const persistedWorkdir = sidebarStore.peek(conversationId)?.cwd?.trim() || ""; - if (workspaceProjectPathKey(runtimeWorkdir || persistedWorkdir) === pathKey) { - return true; - } + for (const [conversationId, activity] of activityStore.getSnapshot().activities) { + const runtimeWorkdir = + activity.workdir?.trim() || + conversationWorkdirsRef.current.get(conversationId)?.trim() || + ""; + const persistedWorkdir = sidebarStore.peek(conversationId)?.cwd?.trim() || ""; + if (workspaceProjectPathKey(runtimeWorkdir || persistedWorkdir) === pathKey) { + return true; } - return false; - }; - - if (projectHasRunningConversation()) { - setSidebarActionError(runningMessage); - return; } + return false; + }; - setSidebarActionError(null); - try { - const conversationIds: string[] = []; - const seenConversationIds = new Set(); - if (path) { - for (let pageNumber = 1; ; pageNumber += 1) { - const page = await currentApi.listHistory( - pageNumber, - PROJECT_HISTORY_DELETE_PAGE_SIZE, - { cwd: path }, - ); - for (const item of page.conversations) { - const id = item.id.trim(); - if (!id || seenConversationIds.has(id)) continue; - seenConversationIds.add(id); - conversationIds.push(id); - } + if (projectHasRunningConversation()) { + setSidebarActionError(translate("chat.workspaceRemoveRunning", settings.locale)); + return; + } - if ( - page.conversations.length === 0 || - conversationIds.length >= page.total_count || - page.conversations.length < PROJECT_HISTORY_DELETE_PAGE_SIZE - ) { - break; - } - } - } + if (options.deleteWorktree !== true) { + setSidebarActionError(null); + removeWorkspaceProjectFromSettings(project); + return; + } - const runningConversationIdsInProject = conversationIds.filter((id) => - isConversationBusy(id), + void (async () => { + const repositoryPath = project.worktree?.repositoryPath.trim() || ""; + if (!path || !pathKey || !repositoryPath) { + setSidebarActionError( + translate("chat.workspaceDeleteWorktreeMetadataMissing", settings.locale), ); - if (runningConversationIdsInProject.length > 0 || projectHasRunningConversation()) { - setSidebarActionError(runningMessage); - return; - } + return; + } + if (!gitClient?.removeWorktree) { + setSidebarActionError( + translate("chat.workspaceDeleteWorktreeUnavailable", settings.locale), + ); + return; + } + setSidebarActionError(null); + try { let terminalSessionsToClose: TerminalSession[] = []; const pruneProjectTerminalSessions = () => { terminalSessionsVersionRef.current += 1; @@ -3026,141 +3158,111 @@ export default function GatewayApp() { }; if ( terminalClient && - (settings.remote.enableWebTerminal || settings.remote.enableWebSshTerminal) && - pathKey + (settings.remote.enableWebTerminal || settings.remote.enableWebSshTerminal) ) { terminalSessionsToClose = await terminalClient.list(pathKey); - const runningTerminalCount = terminalSessionsToClose.filter( - (session) => session.running, - ).length; - if (runningTerminalCount > 0) { - const confirmed = await requestConfirmDialog({ - title: translate("chat.workspaceRemoveConfirm", settings.locale).replace( - "{name}", - project.name, - ), - subtitle: translate("chat.workspaceRemoveDescription", settings.locale), - description: ( -
-
- -
-
-
- - {translate("chat.exitConfirmRunningLabel", settings.locale)} - - - {runningTerminalCount} - -
-

- {translate("chat.workspaceRemoveTerminalDescription", settings.locale)} -

+ } + const runningTerminalCount = terminalSessionsToClose.filter( + (session) => session.running, + ).length; + if (runningTerminalCount > 0) { + const confirmed = await requestConfirmDialog({ + title: translate("chat.workspaceDeleteWorktreeConfirm", settings.locale).replace( + "{name}", + project.name, + ), + subtitle: translate("chat.workspaceDeleteWorktreeDescription", settings.locale), + description: ( +
+
+ +
+
+
+ + {translate("chat.exitConfirmRunningLabel", settings.locale)} + + + {runningTerminalCount} +
+

+ {translate( + "chat.workspaceDeleteWorktreeTerminalDescription", + settings.locale, + )} +

- ), - confirmLabel: translate("chat.workspaceRemoveConfirmContinue", settings.locale), - cancelLabel: translate("chat.cancel", settings.locale), - closeLabel: translate("chat.workspaceRemoveConfirmClose", settings.locale), - tone: "warning", - }); - if (!confirmed) { - return; - } +
+ ), + confirmLabel: translate("chat.workspaceDeleteWorktree", settings.locale), + cancelLabel: translate("chat.cancel", settings.locale), + closeLabel: translate("chat.workspaceDeleteWorktreeConfirmClose", settings.locale), + tone: "warning", + }); + if (!confirmed) { + return; + } + if (terminalClient) { + await terminalClient.closeProject(pathKey); + pruneProjectTerminalSessions(); } } - const visibleConversationId = resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ); - const visibleRuntimeWorkdir = - conversationWorkdirsRef.current.get(visibleConversationId)?.trim() || ""; - const visiblePersistedWorkdir = - sidebarStore.peek(visibleConversationId)?.cwd?.trim() || ""; - const visibleWorkdir = - visiblePersistedWorkdir || - visibleRuntimeWorkdir || - (isAgentMode ? activeWorkspaceProjectPath || settings.system.workdir.trim() : ""); - - for (const conversationId of conversationIds) { - await currentApi.deleteHistory(conversationId); - } - - const deletedConversationIds = new Set(conversationIds); - if (deletedConversationIds.size > 0) { - const nextSharedItems = sharedHistoryItemsRef.current.filter( - (item) => !deletedConversationIds.has(item.id), + const response = await gitClient.removeWorktree(repositoryPath, path, { + deleteBranch: options.deleteBranch === true, + }); + if (!response.worktreeRemoved) { + setSidebarActionError( + response.message || + response.stderr || + translate("chat.workspaceDeleteFailed", settings.locale), ); - sharedHistoryItemsRef.current = nextSharedItems; - setSharedHistoryItems(nextSharedItems); - - for (const conversationId of deletedConversationIds) { - // Immediate local echo; the gateway delete events confirm. - sidebarStore.removeLocal(conversationId); - transcriptStoreRegistry.remove(conversationId); - historyWindowStatesRef.current.delete(conversationId); - conversationWorkdirsRef.current.delete(conversationId); - clearCachedComposerDraft(conversationId); - setPendingUploadsForConversation(conversationId, []); - } + return; } - if (terminalSessionsToClose.length > 0 && terminalClient) { + + if (terminalSessionsToClose.length > 0 && runningTerminalCount === 0 && terminalClient) { await terminalClient.closeProject(pathKey); pruneProjectTerminalSessions(); } - if (pathKey && workspaceProjectPathKey(activeWorkspaceProjectPath) === pathKey) { + if (workspaceProjectPathKey(activeWorkspaceProjectPath) === pathKey) { setRightDockOpen(false); - if (terminalSessionsToClose.length === 0) { - pruneProjectTerminalSessions(); - } } const shouldResetVisibleConversation = - Boolean(visibleConversationId && deletedConversationIds.has(visibleConversationId)) || - Boolean(pathKey && workspaceProjectPathKey(visibleWorkdir) === pathKey); - - if (path) { - await memoryDeleteProject({ - workdir: path, - actor: "tool", - reason: "workspace project removed", - }); - } + workspaceProjectPathKey(displayedConversationWorkdirRef.current) === pathKey; removeWorkspaceProjectFromSettings(project); - // The conversation-removal watcher may already have migrated the - // selection; only reset when the same conversation is still shown. - if ( - shouldResetVisibleConversation && - getDisplayedConversationId() === visibleConversationId.trim() - ) { + if (shouldResetVisibleConversation) { startNewConversation({ workdir: getDefaultWorkspaceProjectPath(settings.system) || undefined, }); } void sidebarStore.refreshWorkdirs("delete"); + if (!response.ok) { + setSidebarActionError( + response.message || + response.stderr || + translate("chat.workspaceDeleteFailed", settings.locale), + ); + } } catch (error) { - setSidebarActionError(asErrorMessage(error, "删除项目失败")); + setSidebarActionError( + asErrorMessage(error, translate("chat.workspaceDeleteFailed", settings.locale)), + ); } })(); }, [ activeWorkspaceProjectPath, activityStore, - api, - clearCachedComposerDraft, - isAgentMode, - isConversationBusy, + gitClient, removeWorkspaceProjectFromSettings, requestConfirmDialog, + settings.locale, settings.remote.enableWebSshTerminal, settings.remote.enableWebTerminal, - settings.locale, settings.system, - setPendingUploadsForConversation, sidebarStore, - startNewConversation, terminalClient, ], ); @@ -4912,6 +5014,7 @@ export default function GatewayApp() { activeView={activeView} showProjects={isAgentMode && status?.online === true} projects={workspaceProjects} + workspaceProjectGroups={settings.system.workspaceProjectGroups} activeProjectId={activeWorkspaceProject?.id} missingProjectPathKeys={missingWorkspaceProjectPathKeys} projectRenamingId={projectRenamingId} @@ -4927,6 +5030,11 @@ export default function GatewayApp() { onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange} onRecentCollapsedChange={handleSidebarRecentCollapsedChange} onCreateProject={handleOpenCreateWorkspaceProject} + onCreateWorkspaceGroup={handleCreateWorkspaceGroup} + onRenameWorkspaceGroup={handleRenameWorkspaceGroup} + onDeleteWorkspaceGroup={handleDeleteWorkspaceGroup} + onMoveProjectToGroup={handleMoveWorkspaceProjectToGroup} + onToggleWorkspaceGroupCollapsed={handleToggleWorkspaceGroupCollapsed} onSelectProject={handleSelectWorkspaceProject} onNewConversationForProject={handleNewConversationForProject} onBrowseProjectInFileTree={handleBrowseWorkspaceProjectInFileTree} @@ -5241,6 +5349,8 @@ export default function GatewayApp() { onManualCompactConfirm={handleManualCompact} manualCompactBlocked={manualCompactPending || composerCompactionBlocked} gitClient={gitClient} + onOpenWorktree={handleOpenWorktree} + onWorktreeRemoved={handleWorktreeRemoved} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} workspaceActivityClient={workspaceActivityClient} diff --git a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx index be0adb764..2305d89b4 100644 --- a/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx +++ b/crates/agent-gateway/web/src/app/sidebar/GatewaySidebarContainer.tsx @@ -3,7 +3,10 @@ // list updates, per-row mutations) re-render this subtree only — never // GatewayApp. Renders the per-end view. -import { ChatHistorySidebar } from "@liveagent/ui/components/chat/ChatHistorySidebar"; +import { + ChatHistorySidebar, + type WorkspaceProjectRemoveOptions, +} from "@liveagent/ui/components/chat/ChatHistorySidebar"; import { useLocale } from "@liveagent/ui/i18n/index"; import type { SidebarBatchDeleteOptions } from "@liveagent/ui/lib/sidebar/batchDelete"; import { deleteSidebarConversations } from "@liveagent/ui/lib/sidebar/batchDelete"; @@ -20,6 +23,7 @@ import { mergeTransientSidebarRunningActivity } from "@liveagent/ui/lib/sidebar/ import type { SidebarErrorCode } from "@liveagent/ui/lib/sidebar/types"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { sortWorkspaceProjectsByActivity } from "@liveagent/ui/lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChatHistorySummary } from "@/lib/chat/chatHistory"; import type { WorkspaceProject } from "@/lib/settings"; @@ -86,6 +90,7 @@ export type GatewaySidebarContainerProps = { // the store's activity snapshot so project reordering never re-renders // GatewayApp. projects: WorkspaceProject[]; + workspaceProjectGroups?: WorkspaceProjectGroup[]; activeProjectId?: string; missingProjectPathKeys: ReadonlySet; projectRenamingId: string | null; @@ -107,6 +112,11 @@ export type GatewaySidebarContainerProps = { onProjectsCollapsedChange: (collapsed: boolean) => void; onRecentCollapsedChange: (collapsed: boolean) => void; onCreateProject: () => void; + onCreateWorkspaceGroup?: (name: string) => void; + onRenameWorkspaceGroup?: (groupId: string, name: string) => void; + onDeleteWorkspaceGroup?: (groupId: string) => void; + onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void; + onToggleWorkspaceGroupCollapsed?: (groupId: string) => void; onSelectProject: (project: WorkspaceProject) => void; onNewConversationForProject: (project: WorkspaceProject) => void; onBrowseProjectInFileTree: (project: WorkspaceProject) => void; @@ -116,7 +126,7 @@ export type GatewaySidebarContainerProps = { onCommitProjectRename: () => void; onCancelProjectRename: () => void; onSetProjectPinned: (project: WorkspaceProject, isPinned: boolean) => void; - onRemoveProject: (project: WorkspaceProject) => void; + onRemoveProject: (project: WorkspaceProject, options?: WorkspaceProjectRemoveOptions) => void; onArchiveProject: (project: WorkspaceProject) => void; onUnarchiveProject: (project: WorkspaceProject) => void; archivedProjectPathKeys?: ReadonlySet; @@ -383,6 +393,7 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { activeView={props.activeView} showProjects={props.showProjects} projects={sortedProjects} + workspaceProjectGroups={props.workspaceProjectGroups} activeProjectId={props.activeProjectId} missingProjectPathKeys={props.missingProjectPathKeys} runningProjectPathKeys={effectiveRunningActivity.runningProjectPathKeys} @@ -393,6 +404,11 @@ export function GatewaySidebarContainer(props: GatewaySidebarContainerProps) { onProjectsCollapsedChange={props.onProjectsCollapsedChange} onRecentCollapsedChange={props.onRecentCollapsedChange} onCreateProject={props.onCreateProject} + onCreateWorkspaceGroup={props.onCreateWorkspaceGroup} + onRenameWorkspaceGroup={props.onRenameWorkspaceGroup} + onDeleteWorkspaceGroup={props.onDeleteWorkspaceGroup} + onMoveProjectToGroup={props.onMoveProjectToGroup} + onToggleWorkspaceGroupCollapsed={props.onToggleWorkspaceGroupCollapsed} onSelectProject={props.onSelectProject} onNewConversationForProject={props.onNewConversationForProject} onBrowseProjectInFileTree={props.onBrowseProjectInFileTree} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index d067e936b..05c561866 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -37,6 +37,18 @@ export const translations: Record> = { "chat.recentConversation": "最近会话", "chat.workspaceSection": "工作空间", "chat.workspaceCreate": "新建工作空间", + "chat.workspaceAdd": "添加…", + "chat.workspaceUngrouped": "未分组", + "chat.workspaceGroupCreate": "新建分组", + "chat.workspaceGroupNamePlaceholder": "分组名称", + "chat.workspaceGroupRename": "重命名分组", + "chat.workspaceGroupDelete": "删除分组", + "chat.workspaceGroupDeleteConfirmTitle": "删除分组「{name}」?", + "chat.workspaceGroupDeleteConfirmDescription": "组内项目将回到未分组,项目本身不会被删除。", + "chat.workspaceGroupToggle": "展开/折叠分组", + "chat.workspaceGroupActions": "分组操作", + "chat.workspaceGroupMove": "移动到分组", + "chat.workspaceGroupUngroup": "移出分组", "chat.workspaceCreateDescription": "打开已有文件夹,或从远程 Git 仓库创建新的工作空间。", "chat.workspaceOpenFolder": "打开本地文件夹", "chat.workspaceOpenFolderDescription": "将已有文件夹添加为工作空间。", @@ -83,6 +95,7 @@ export const translations: Record> = { "chat.workspaceUnpin": "取消置顶", "chat.workspaceRename": "修改标题", "chat.workspaceRemove": "移除工作空间", + "chat.workspaceRemoveOnly": "移除工作空间", "chat.workspaceArchive": "归档", "chat.workspaceUnarchive": "取消归档", "chat.workspaceArchivedGroup": "已归档({count})", @@ -92,11 +105,18 @@ export const translations: Record> = { "chat.workspaceShowLessProjects": "收起", "chat.workspaceRemoveConfirm": "移除「{name}」?", "chat.workspaceRemoveRunning": "后台任务运行中,暂时不能移除。", - "chat.workspaceRemoveDescription": "会删除此工作空间下的历史对话,不会删除文件夹。", + "chat.workspaceRemoveDescription": "只从侧边栏移除该工作空间,历史对话与文件夹都会保留。", "chat.exitConfirmRunningLabel": "正在运行的 Terminal", - "chat.workspaceRemoveTerminalDescription": "删除项目会关闭这些 Terminal 进程。", - "chat.workspaceRemoveConfirmContinue": "删除项目", - "chat.workspaceRemoveConfirmClose": "关闭删除项目确认", + "chat.workspaceDeleteWorktree": "删除 Worktree", + "chat.workspaceDeleteWorktreeConfirm": "删除 Worktree「{name}」?", + "chat.workspaceDeleteWorktreeDescription": "会删除磁盘上的 Worktree 目录,历史对话保留。", + "chat.workspaceDeleteWorktreeBranch": "同时删除分支「{branch}」", + "chat.workspaceDeleteWorktreeTerminalDescription": "删除 Worktree 会关闭这些 Terminal 进程。", + "chat.workspaceDeleteWorktreeConfirmClose": "关闭删除 Worktree 确认", + "chat.workspaceDeleteWorktreeMetadataMissing": + "缺少 Worktree 信息,无法删除;请改用「移除工作空间」。", + "chat.workspaceDeleteWorktreeUnavailable": "当前环境不支持删除 Worktree。", + "chat.workspaceDeleteFailed": "删除 Worktree 失败", "chat.conversationMore": "更多操作", "chat.conversationPin": "置顶对话", "chat.conversationUnpin": "取消置顶", @@ -767,9 +787,39 @@ export const translations: Record> = { "git.branchSelector.deleteForceTitle": "分支尚未完全合并", "git.branchSelector.deleteForceDescription": "强制删除(-D)会丢弃仅存在于该分支上的提交。", "git.branchSelector.forceDelete": "强制删除", + "git.branchSelector.deleteWorktree": "删除 Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": "删除 Worktree「{path}」?", + "git.branchSelector.deleteWorktreeConfirmDescription": + "将删除磁盘上的 Worktree 目录及其在仓库中的登记。", + "git.branchSelector.deleteWorktreeBranchTitle": "同时删除分支「{branch}」?", + "git.branchSelector.deleteWorktreeBranchDescription": + "可以连同 Worktree 一起删除该分支,也可以先保留分支稍后处理。", + "git.branchSelector.deleteWorktreeAndBranch": "删除 Worktree 和分支", + "git.branchSelector.keepWorktreeBranch": "保留分支", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree 包含未提交改动", + "git.branchSelector.deleteWorktreeForceDescription": + "强制移除(--force)会丢弃 Worktree 中的未提交改动。", + "git.branchSelector.forceRemoveWorktree": "强制移除", "git.branchSelector.moreActions": "更多操作", "git.branchSelector.stashPush": "暂存当前改动 (stash)", "git.branchSelector.stashPop": "恢复最近的 stash", + "git.branchSelector.createWorktree": "新建 Worktree", + "git.branchSelector.createWorktreeTitle": "新建 Worktree", + "git.branchSelector.worktreeDescription": "在独立目录检出仓库副本,可并行开发多个分支。", + "git.branchSelector.worktreeStartPoint": "基于分支", + "git.branchSelector.worktreeBranch": "新分支名", + "git.branchSelector.worktreeBranchPlaceholder": "feature/my-branch", + "git.branchSelector.worktreeDirectoryName": "目录名", + "git.branchSelector.worktreeDirectoryPlaceholder": "feature-my-branch", + "git.branchSelector.worktreeParentDirectory": "保存位置", + "git.branchSelector.worktreeDefaultLocation": "默认位置(~/.liveagent/worktree)", + "git.branchSelector.worktreeUseDefaultLocation": "恢复默认位置", + "git.branchSelector.worktreeChooseParent": "选择…", + "git.branchSelector.worktreeLocationHint": + "将保存到 ~/.liveagent/worktree 下的独立目录,创建后自动在侧边栏打开。", + "git.branchSelector.worktreeCustomLocationHint": + "将在所选目录下创建 Worktree 文件夹,创建后自动在侧边栏打开。", + "git.branchSelector.worktreeFailed": "创建 Worktree 失败", "projectTools.reorderTab": "调整标签排序", "projectTools.reorderTabHint": "拖动排序,或聚焦后按左右方向键移动", "projectTools.shell": "Shell", @@ -2263,6 +2313,19 @@ export const translations: Record> = { "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", "chat.workspaceCreate": "New workspace", + "chat.workspaceAdd": "Add…", + "chat.workspaceUngrouped": "Ungrouped", + "chat.workspaceGroupCreate": "New Group", + "chat.workspaceGroupNamePlaceholder": "Group name", + "chat.workspaceGroupRename": "Rename group", + "chat.workspaceGroupDelete": "Delete group", + "chat.workspaceGroupDeleteConfirmTitle": 'Delete group "{name}"?', + "chat.workspaceGroupDeleteConfirmDescription": + "Projects in the group move back to ungrouped; the projects themselves are not deleted.", + "chat.workspaceGroupToggle": "Toggle group", + "chat.workspaceGroupActions": "Group actions", + "chat.workspaceGroupMove": "Move to group", + "chat.workspaceGroupUngroup": "Ungroup", "chat.workspaceCreateDescription": "Open an existing folder or create a new workspace from a remote Git repository.", "chat.workspaceOpenFolder": "Open local folder", @@ -2314,6 +2377,7 @@ export const translations: Record> = { "chat.workspaceUnpin": "Unpin", "chat.workspaceRename": "Rename", "chat.workspaceRemove": "Remove workspace", + "chat.workspaceRemoveOnly": "Remove workspace", "chat.workspaceArchive": "Archive", "chat.workspaceUnarchive": "Unarchive", "chat.workspaceArchivedGroup": "Archived ({count})", @@ -2325,12 +2389,20 @@ export const translations: Record> = { "chat.workspaceRemoveRunning": "A background task is running, so this workspace cannot be removed yet.", "chat.workspaceRemoveDescription": - "This deletes conversations under the workspace, but it does not delete the folder.", + "Removes this workspace from the sidebar; conversations and the folder are kept.", "chat.exitConfirmRunningLabel": "Running Terminal sessions", - "chat.workspaceRemoveTerminalDescription": - "Deleting the project will close these Terminal processes.", - "chat.workspaceRemoveConfirmContinue": "Delete project", - "chat.workspaceRemoveConfirmClose": "Close project deletion confirmation", + "chat.workspaceDeleteWorktree": "Delete Worktree", + "chat.workspaceDeleteWorktreeConfirm": 'Delete worktree "{name}"?', + "chat.workspaceDeleteWorktreeDescription": + "Deletes the worktree directory on disk; conversations are kept.", + "chat.workspaceDeleteWorktreeBranch": 'Also delete branch "{branch}"', + "chat.workspaceDeleteWorktreeTerminalDescription": + "Deleting the worktree will close these Terminal processes.", + "chat.workspaceDeleteWorktreeConfirmClose": "Close worktree deletion confirmation", + "chat.workspaceDeleteWorktreeMetadataMissing": + "Worktree metadata is missing; use Remove workspace instead.", + "chat.workspaceDeleteWorktreeUnavailable": "Deleting worktrees is not available here.", + "chat.workspaceDeleteFailed": "Failed to delete worktree", "chat.conversationMore": "More actions", "chat.conversationPin": "Pin conversation", "chat.conversationUnpin": "Unpin", @@ -3029,9 +3101,40 @@ export const translations: Record> = { "git.branchSelector.deleteForceDescription": "Force delete (-D) discards commits that only exist on this branch.", "git.branchSelector.forceDelete": "Force delete", + "git.branchSelector.deleteWorktree": "Delete Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": 'Delete worktree "{path}"?', + "git.branchSelector.deleteWorktreeConfirmDescription": + "Deletes the worktree directory on disk and unregisters it from the repository.", + "git.branchSelector.deleteWorktreeBranchTitle": 'Also delete branch "{branch}"?', + "git.branchSelector.deleteWorktreeBranchDescription": + "Delete the branch together with the worktree, or keep it for later.", + "git.branchSelector.deleteWorktreeAndBranch": "Delete worktree and branch", + "git.branchSelector.keepWorktreeBranch": "Keep branch", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree contains uncommitted changes", + "git.branchSelector.deleteWorktreeForceDescription": + "Force removal (--force) discards uncommitted changes in the worktree.", + "git.branchSelector.forceRemoveWorktree": "Force remove", "git.branchSelector.moreActions": "More actions", "git.branchSelector.stashPush": "Stash changes", "git.branchSelector.stashPop": "Pop latest stash", + "git.branchSelector.createWorktree": "Create Worktree", + "git.branchSelector.createWorktreeTitle": "Create Worktree", + "git.branchSelector.worktreeDescription": + "Check out a separate copy of the repository to work on multiple branches in parallel.", + "git.branchSelector.worktreeStartPoint": "Start point", + "git.branchSelector.worktreeBranch": "New branch", + "git.branchSelector.worktreeBranchPlaceholder": "feature/my-branch", + "git.branchSelector.worktreeDirectoryName": "Directory name", + "git.branchSelector.worktreeDirectoryPlaceholder": "feature-my-branch", + "git.branchSelector.worktreeParentDirectory": "Location", + "git.branchSelector.worktreeDefaultLocation": "Default location (~/.liveagent/worktree)", + "git.branchSelector.worktreeUseDefaultLocation": "Use default location", + "git.branchSelector.worktreeChooseParent": "Choose…", + "git.branchSelector.worktreeLocationHint": + "Saved under ~/.liveagent/worktree in a separate directory and opened in the sidebar automatically.", + "git.branchSelector.worktreeCustomLocationHint": + "The worktree folder is created inside the chosen directory and opened in the sidebar automatically.", + "git.branchSelector.worktreeFailed": "Failed to create worktree", "projectTools.reorderTab": "Reorder tab", "projectTools.reorderTabHint": "Drag to reorder, or focus and use Left/Right", "projectTools.shell": "Shell", diff --git a/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts b/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts index 73e481fce..f1de1a56d 100644 --- a/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts +++ b/crates/agent-gateway/web/src/lib/git/gatewayGitClient.ts @@ -5,8 +5,10 @@ import { normalizeGitDiffResponse, normalizeGitLogResponse, normalizeGitOperationResponse, + normalizeGitRemoveWorktreeResponse, normalizeGitRepositoryDiscovery, normalizeGitRepositoryState, + normalizeGitWorktreeResponse, } from "@liveagent/ui/lib/git/types"; import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; @@ -46,6 +48,17 @@ export function createGatewayGitClient(api: GatewayWebSocketClientLike): GitClie workdir, ); }, + async createWorktree(workdir, options) { + return normalizeGitWorktreeResponse( + await api.gitRequest("create_worktree", workdir, { + branch: options.branch, + directoryName: options.directoryName, + parentDirectory: options.parentDirectory, + startPoint: options.startPoint, + }), + workdir, + ); + }, async diff(workdir, mode, path) { return normalizeGitDiffResponse(await api.gitRequest("diff", workdir, { mode, path })); }, @@ -137,6 +150,16 @@ export function createGatewayGitClient(api: GatewayWebSocketClientLike): GitClie workdir, ); }, + async removeWorktree(workdir, worktreePath, options = {}) { + return normalizeGitRemoveWorktreeResponse( + await api.gitRequest("remove_worktree", workdir, { + worktreePath, + force: options.force, + deleteBranch: options.deleteBranch, + }), + workdir, + ); + }, async stashPush(workdir, message) { return normalizeGitOperationResponse( await api.gitRequest("stash_push", workdir, { message }), diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 408c4e7ae..20fd435eb 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -24,6 +24,7 @@ import { MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRANSCRIPT_WIDTH, } from "@liveagent/ui/lib/transcript-width/transcriptWidthModel"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { DEFAULT_LOCALE, type Locale, normalizeLocale } from "../../i18n/config"; import { normalizeFontFamily } from "../fontFamily"; @@ -246,6 +247,7 @@ export type SystemSettings = { */ toolPolicies?: Record; workspaceProjects: WorkspaceProject[]; + workspaceProjectGroups: WorkspaceProjectGroup[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; missingWorkspaceProjectPaths: string[]; @@ -282,6 +284,10 @@ export type WorkspaceProject = { name: string; path: string; kind: WorkspaceProjectKind; + worktree?: { + repositoryPath: string; + branch?: string; + }; createdAt: number; updatedAt: number; lastConversationAt?: number; @@ -801,6 +807,20 @@ function normalizeWorkspaceProjectKind(input: unknown): WorkspaceProjectKind { } } +function normalizeWorkspaceProjectWorktree( + input: unknown, +): WorkspaceProject["worktree"] | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; + const obj = input as Record; + const repositoryPath = normalizeWorkspaceProjectPath(obj.repositoryPath); + if (!repositoryPath) return undefined; + const branch = typeof obj.branch === "string" ? obj.branch.trim() : ""; + return { + repositoryPath, + ...(branch ? { branch } : {}), + }; +} + function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null { const obj = (input && typeof input === "object" ? input : {}) as Record; const path = normalizeWorkspaceProjectPath(obj.path); @@ -832,11 +852,13 @@ function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null { typeof obj.pinnedAt === "number" && Number.isFinite(obj.pinnedAt) && obj.pinnedAt > 0 ? obj.pinnedAt : undefined; + const worktree = normalizeWorkspaceProjectWorktree(obj.worktree); return { id, name, path, kind: normalizeWorkspaceProjectKind(obj.kind), + ...(worktree ? { worktree } : {}), createdAt, updatedAt, ...(lastConversationAt ? { lastConversationAt } : {}), @@ -865,6 +887,47 @@ function normalizeWorkspaceProjects(input: unknown): WorkspaceProject[] { return out; } +function normalizeWorkspaceProjectGroups(input: unknown): WorkspaceProjectGroup[] { + if (!Array.isArray(input)) return []; + const out: WorkspaceProjectGroup[] = []; + const seenIds = new Set(); + for (const raw of input) { + const obj = (raw && typeof raw === "object" ? raw : {}) as Record; + const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(); + if (seenIds.has(id)) continue; + const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : "未命名分组"; + const projectPaths: string[] = []; + const seenPaths = new Set(); + for (const path of normalizeStringArray(obj.projectPaths)) { + const normalizedPath = normalizeWorkspaceProjectPath(path); + const pathKey = workspaceProjectPathKey(normalizedPath); + if (!pathKey || seenPaths.has(pathKey)) continue; + seenPaths.add(pathKey); + projectPaths.push(normalizedPath); + } + const sourceProjectPath = normalizeWorkspaceProjectPath(obj.sourceProjectPath); + const createdAt = + typeof obj.createdAt === "number" && Number.isFinite(obj.createdAt) && obj.createdAt > 0 + ? obj.createdAt + : Date.now(); + const updatedAt = + typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) && obj.updatedAt > 0 + ? obj.updatedAt + : createdAt; + seenIds.add(id); + out.push({ + id, + name, + projectPaths, + ...(sourceProjectPath ? { sourceProjectPath } : {}), + ...(obj.collapsed === true ? { collapsed: true } : {}), + createdAt, + updatedAt, + }); + } + return out; +} + export function normalizeHiddenWorkspaceProjectPaths(input: unknown): string[] { const out: string[] = []; const seen = new Set(); @@ -923,6 +986,7 @@ export function resolveWorkspaceProjects( kind: "managed", createdAt: defaultExisting?.createdAt ?? now, updatedAt: defaultExisting?.updatedAt ?? now, + ...(defaultExisting?.worktree ? { worktree: defaultExisting.worktree } : {}), ...(defaultExisting?.lastConversationAt ? { lastConversationAt: defaultExisting.lastConversationAt } : {}), @@ -1843,6 +1907,7 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), + workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() ? obj.activeWorkspaceProjectId.trim() @@ -2493,6 +2558,7 @@ export function getDefaultSettings(): AppSettings { executionMode: "tools", workdir: "", workspaceProjects: [], + workspaceProjectGroups: [], activeWorkspaceProjectId: undefined, hiddenWorkspaceProjectPaths: [], missingWorkspaceProjectPaths: [], diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index c348c9a46..8fd871a4a 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -33,6 +33,7 @@ const SYSTEM_WORKDIR_KEY: &str = "workdir"; // 保存白名单,导致重启后设置丢失;补入本键持久化。 const SYSTEM_TOOL_POLICIES_KEY: &str = "toolPolicies"; const SYSTEM_WORKSPACE_PROJECTS_KEY: &str = "workspaceProjects"; +const SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY: &str = "workspaceProjectGroups"; const SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY: &str = "activeWorkspaceProjectId"; const SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY: &str = "hiddenWorkspaceProjectPaths"; const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjectPaths"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index 13e39c380..00954b9ca 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -501,6 +501,7 @@ fn save_system_with_default_workdir( SYSTEM_WORKDIR_KEY, SYSTEM_TOOL_POLICIES_KEY, SYSTEM_WORKSPACE_PROJECTS_KEY, + SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY, SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY, SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY, diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 5f44fe32e..e85296425 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1121,7 +1121,7 @@ mod tests { }; let loaded = load_system(&conn).expect("load system"); - assert_eq!(row_count, 10); + assert_eq!(row_count, 11); assert_eq!( keys, vec![ @@ -1133,6 +1133,7 @@ mod tests { SYSTEM_SYSTEM_PROXY_KEY.to_string(), SYSTEM_TOOL_POLICIES_KEY.to_string(), SYSTEM_WORKDIR_KEY.to_string(), + SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY.to_string(), SYSTEM_WORKSPACE_PROJECTS_KEY.to_string(), SYSTEM_WORKSPACE_RESOURCE_SETTINGS_KEY.to_string(), ] @@ -1149,6 +1150,7 @@ mod tests { "systemProxy": default_system_proxy_json(), "workdir": default_workdir.clone(), "toolPolicies": { "Bash": "ask", "server:docs-mcp": "deny" }, + "workspaceProjectGroups": null, "workspaceProjects": [ { "id": DEFAULT_WORKSPACE_PROJECT_ID, @@ -1191,6 +1193,49 @@ mod tests { ); } + #[test] + fn save_system_round_trips_workspace_project_groups() { + let mut conn = open_memory_db(); + save_system_with_default_workdir( + &mut conn, + json!({ + "executionMode": "tools", + "workdir": "/tmp/liveagent-default-project", + "workspaceProjectGroups": [ + { + "id": "g1", + "name": "LiveAgent", + "projectPaths": ["/tmp/repo", "/tmp/wt"], + "sourceProjectPath": "/tmp/repo", + "collapsed": true, + "createdAt": 100, + "updatedAt": 100 + } + ] + }), + "/tmp/liveagent-default-project", + ) + .expect("save system"); + + let loaded = load_system(&conn) + .expect("load system") + .expect("system settings"); + assert_eq!( + loaded.get(SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY), + Some(&json!([ + { + "id": "g1", + "name": "LiveAgent", + "projectPaths": ["/tmp/repo", "/tmp/wt"], + "sourceProjectPath": "/tmp/repo", + "collapsed": true, + "createdAt": 100, + "updatedAt": 100 + } + ])) + ); + } + #[test] fn save_system_normalizes_workspace_resource_settings() { let now = std::time::SystemTime::now() @@ -1403,6 +1448,7 @@ mod tests { "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, + "workspaceProjectGroups": null, "workspaceProjects": [ { "id": DEFAULT_WORKSPACE_PROJECT_ID, @@ -1455,6 +1501,7 @@ mod tests { "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, + "workspaceProjectGroups": null, "workspaceProjects": [ { "id": DEFAULT_WORKSPACE_PROJECT_ID, diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 90c2f791a..d1bd9673f 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -105,6 +105,16 @@ pub struct GitBranch { pub struct GitBranchesResponse { pub state: GitRepositoryState, pub branches: Vec, + pub worktrees: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeInfo { + pub path: String, + pub branch: String, + pub main_worktree_path: String, + pub is_current: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -201,6 +211,36 @@ pub struct GitOperationResponse { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeResponse { + pub ok: bool, + pub state: GitRepositoryState, + pub worktree_path: String, + pub branch: String, + pub directory_name: String, + pub main_worktree_path: String, + pub stdout: String, + pub stderr: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitRemoveWorktreeResponse { + pub ok: bool, + pub state: GitRepositoryState, + pub worktree_path: String, + pub main_worktree_path: String, + pub branch: String, + pub worktree_removed: bool, + pub branch_delete_requested: bool, + pub branch_deleted: bool, + pub stdout: String, + pub stderr: String, + pub message: String, +} + #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] struct GitGatewayArgs { @@ -216,10 +256,14 @@ struct GitGatewayArgs { limit: Option, skip: Option, name: Option, + directory_name: Option, + parent_directory: Option, user_name: Option, user_email: Option, force: Option, new_branch: Option, + worktree_path: Option, + delete_branch: Option, task_id: Option, } @@ -228,6 +272,15 @@ struct GitOutput { stderr: String, } +#[derive(Debug, Clone, Default)] +struct GitWorktreeRecord { + path: String, + branch: String, + is_main: bool, + is_current: bool, + locked: bool, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitCloneTask { @@ -1087,6 +1140,7 @@ pub(crate) fn git_branches_sync(workdir: String) -> Result Result PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn worktree_paths_match(left: &Path, right: &Path) -> bool { + normalized_worktree_path(left) == normalized_worktree_path(right) +} + +/// `--porcelain -z` 让字段与记录都由 NUL 分隔,路径中的换行符不会破坏解析。 +/// 第一条记录由 Git 定义为主工作树;detached / prunable / locked 字段可直接忽略。 +fn parse_git_worktree_records(output: &str, current_repo_root: &str) -> Vec { + let mut records = Vec::new(); + let mut record = GitWorktreeRecord::default(); + + for field in output.split('\0') { + if field.is_empty() { + if !record.path.is_empty() { + records.push(std::mem::take(&mut record)); + } + continue; + } + if let Some(path) = field.strip_prefix("worktree ") { + if !record.path.is_empty() { + records.push(std::mem::take(&mut record)); + } + record.path = path.to_string(); + } else if let Some(branch) = field.strip_prefix("branch refs/heads/") { + record.branch = branch.to_string(); + } else if field == "locked" || field.starts_with("locked ") { + record.locked = true; + } + } + if !record.path.is_empty() { + records.push(record); + } + + let current_path = Path::new(current_repo_root); + for (index, record) in records.iter_mut().enumerate() { + record.is_main = index == 0; + record.is_current = worktree_paths_match(Path::new(&record.path), current_path); + } + records +} + +fn git_worktree_records_sync(repo_root: &str) -> Result, String> { + let output = git_success(repo_root, &["worktree", "list", "--porcelain", "-z"])?; + let records = parse_git_worktree_records(&output.stdout, repo_root); + if records.is_empty() { + return Err("Git 未返回 Worktree 登记信息。".to_string()); + } + Ok(records) +} + +/// 返回 linked worktree,主工作树不暴露为可删除项;每条记录携带稳定的主工作树 +/// 路径与“当前项目”标记,调用方从 linked worktree 内查询时也能正确识别自身。 +fn git_worktrees_sync(repo_root: &str) -> Result, String> { + let records = git_worktree_records_sync(repo_root)?; + let main_worktree_path = records + .first() + .map(|record| record.path.clone()) + .ok_or_else(|| "Git 未返回主 Worktree。".to_string())?; + Ok(records + .into_iter() + .filter(|record| !record.is_main) + .map(|record| GitWorktreeInfo { + path: record.path, + branch: record.branch, + main_worktree_path: main_worktree_path.clone(), + is_current: record.is_current, + }) + .collect()) } fn ensure_ready_state(workdir: &str) -> Result { @@ -1696,6 +1830,210 @@ pub(crate) fn git_create_branch_sync( ) } +/// Worktree 存储基目录(`~/.liveagent/worktree`)。Worktree 是仓库的检出 +/// 副本,落在应用存储域,避免污染工作区目录结构。 +fn worktree_storage_base() -> Result { + let home = dirs::home_dir().ok_or_else(|| "无法定位用户目录。".to_string())?; + let dir = home.join(".liveagent").join("worktree"); + fs::create_dir_all(&dir).map_err(|error| format!("创建 worktree 目录失败:{error}"))?; + Ok(dir) +} + +/// 稳定且唯一的 repo id:`-`。 +/// 同一仓库根路径永远映射到同一 id,目录可读;64 位哈希碰撞概率可忽略。 +fn repo_worktree_id(repo_root: &str) -> String { + let basename = Path::new(repo_root) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| repo_root.to_string()); + let sanitized = sanitize_repo_id_component(&basename); + format!("{sanitized}-{:016x}", fnv1a64(repo_root.as_bytes())) +} + +fn sanitize_repo_id_component(input: &str) -> String { + let mut out = String::new(); + for ch in input.trim().chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { + out.push(ch); + } else { + out.push('-'); + } + } + let compact = out + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + let trimmed = compact + .trim_matches(|ch| ch == '-' || ch == '.') + .to_string(); + if trimmed.is_empty() { + "repo".to_string() + } else { + trimmed.chars().take(80).collect() + } +} + +/// FNV-1a 64 位哈希,与前端展示无关、仅用于目录命名,无需引入额外依赖。 +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn validate_worktree_parent_directory(parent: &str) -> Result { + let parent = parent.trim(); + if parent.is_empty() { + return Err("Worktree 父目录不能为空。".to_string()); + } + let parent_path = PathBuf::from(parent); + if !parent_path.is_absolute() { + return Err(format!("Worktree 父目录必须是绝对路径:{parent}")); + } + let metadata = + fs::metadata(&parent_path).map_err(|error| format!("Worktree 父目录不可访问:{error}"))?; + if !metadata.is_dir() { + return Err("Worktree 父目录必须是文件夹。".to_string()); + } + fs::read_dir(&parent_path).map_err(|error| format!("Worktree 父目录不可访问:{error}"))?; + fs::canonicalize(&parent_path).map_err(|error| format!("无法解析 Worktree 父目录:{error}")) +} + +pub(crate) fn git_create_worktree_sync( + workdir: String, + branch: String, + directory_name: String, + parent_directory: Option, + start_point: Option, +) -> Result { + let managed_base = if parent_directory.is_none() { + Some(worktree_storage_base()?) + } else { + None + }; + git_create_worktree_with_base( + workdir, + branch, + directory_name, + parent_directory, + start_point, + managed_base.as_deref(), + ) +} + +/// 默认基目录由调用方注入(生产为 `~/.liveagent/worktree`,测试传临时目录); +/// 显式 parent_directory 存在时直接使用经过校验的用户目录。 +fn git_create_worktree_with_base( + workdir: String, + branch: String, + directory_name: String, + parent_directory: Option, + start_point: Option, + managed_base: Option<&Path>, +) -> Result { + let state = ensure_ready_state(&workdir)?; + let repo_root = + fs::canonicalize(&state.repo_root).map_err(|error| format!("无法解析仓库路径:{error}"))?; + let repo_root_str = repo_root.to_string_lossy().into_owned(); + let records = git_worktree_records_sync(&repo_root_str)?; + let main_worktree_path = records + .first() + .map(|record| normalized_worktree_path(Path::new(&record.path))) + .ok_or_else(|| "Git 未返回主 Worktree。".to_string())?; + let main_worktree_path = main_worktree_path.to_string_lossy().into_owned(); + let branch = validate_branch_name(&repo_root_str, &branch)?; + let directory_name = validate_project_folder_name(&directory_name)?.to_string(); + let target_parent = match parent_directory.as_deref() { + Some(parent) => validate_worktree_parent_directory(parent)?, + None => { + let base = managed_base.ok_or_else(|| "缺少默认 Worktree 存储目录。".to_string())?; + let repo_dir = base.join(repo_worktree_id(&main_worktree_path)); + fs::create_dir_all(&repo_dir) + .map_err(|error| format!("创建 Worktree 目录失败:{error}"))?; + fs::canonicalize(&repo_dir) + .map_err(|error| format!("无法解析 Worktree 目录:{error}"))? + } + }; + let target = target_parent.join(&directory_name); + if target + .try_exists() + .map_err(|error| format!("无法检查 Worktree 目标:{error}"))? + { + return Err(format!("Worktree 目标已存在:{}", target.display())); + } + if records + .iter() + .any(|record| target.starts_with(normalized_worktree_path(Path::new(&record.path)))) + { + return Err("Worktree 目标不能位于现有 Worktree 目录内。".to_string()); + } + + let validated_start_point = start_point + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| validate_start_point(&repo_root_str, value)) + .transpose()?; + let start_point = validated_start_point.unwrap_or_else(|| "HEAD".to_string()); + let target_path = target.to_string_lossy().into_owned(); + let result = git_success( + &repo_root_str, + &[ + "worktree", + "add", + "-b", + branch.as_str(), + target_path.as_str(), + start_point.as_str(), + ], + ); + + let response_state = git_status_sync(workdir)?; + match result { + Ok(output) => { + let worktree_path = fs::canonicalize(&target) + .map_err(|error| format!("无法解析 Worktree 路径:{error}"))? + .to_string_lossy() + .into_owned(); + Ok(GitWorktreeResponse { + ok: true, + state: response_state, + worktree_path, + branch, + directory_name, + main_worktree_path, + stdout: output.stdout, + stderr: output.stderr, + message: "Worktree 已创建。".to_string(), + }) + } + Err(error) => Ok(GitWorktreeResponse { + ok: false, + state: response_state, + worktree_path: target_path, + branch, + directory_name, + main_worktree_path, + stdout: String::new(), + stderr: error.clone(), + message: error, + }), + } +} + +#[cfg(test)] +fn git_create_worktree_in_base( + workdir: String, + name: String, + start_point: Option, + base: &Path, +) -> Result { + git_create_worktree_with_base(workdir, name.clone(), name, None, start_point, Some(base)) +} + pub(crate) fn git_init_sync( workdir: String, branch: String, @@ -2944,11 +3282,165 @@ pub(crate) fn git_delete_branch_sync( return Err("不能删除当前检出的分支。".to_string()); } let delete_flag = if force == Some(true) { "-D" } else { "-d" }; - operation_response( - &workdir, - git_success(&state.repo_root, &["branch", delete_flag, branch.as_str()]), - "分支已删除。", - ) + let result = git_success(&state.repo_root, &["worktree", "prune", "--expire", "now"]) + .and_then(|_| git_success(&state.repo_root, &["branch", delete_flag, branch.as_str()])); + operation_response(&workdir, result, "分支已删除。") +} + +fn select_worktree_control_path( + records: &[GitWorktreeRecord], + target: &GitWorktreeRecord, +) -> Result { + records + .iter() + .find(|record| { + !worktree_paths_match(Path::new(&record.path), Path::new(&target.path)) + && Path::new(&record.path).is_dir() + }) + .map(|record| record.path.clone()) + .ok_or_else(|| "找不到可用于移除 Worktree 的存活工作树。".to_string()) +} + +/// 移除 Worktree,成功后可选删除其真实关联分支。调用方只能提供布尔选项, +/// 分支名必须来自 Git 的 Worktree 登记;主工作树永远不可删除。 +pub(crate) fn git_remove_worktree_sync( + workdir: String, + worktree_path: String, + force: Option, + delete_branch: Option, +) -> Result { + let state = ensure_ready_state(&workdir)?; + let trimmed = worktree_path.trim(); + if trimmed.is_empty() { + return Err("Worktree 路径不能为空。".to_string()); + } + let requested_path = PathBuf::from(trimmed); + if !requested_path.is_absolute() { + return Err("Worktree 路径必须是绝对路径。".to_string()); + } + + let records = git_worktree_records_sync(&state.repo_root)?; + let target = records + .iter() + .find(|record| worktree_paths_match(Path::new(&record.path), &requested_path)) + .cloned() + .ok_or_else(|| "目标路径不是当前仓库已登记的 Worktree。".to_string())?; + if target.is_main { + return Err("不能删除主 Worktree。".to_string()); + } + + let control_workdir = if target.is_current { + select_worktree_control_path(&records, &target)? + } else { + state.repo_root.clone() + }; + let main_worktree_path = records + .first() + .map(|record| normalized_worktree_path(Path::new(&record.path))) + .ok_or_else(|| "Git 未返回主 Worktree。".to_string())? + .to_string_lossy() + .into_owned(); + let registered_path = target.path.clone(); + let branch = target.branch.clone(); + let branch_delete_requested = delete_branch == Some(true); + let mut args = vec!["worktree", "remove"]; + if force == Some(true) { + args.push("--force"); + if target.locked { + args.push("--force"); + } + } + args.extend(["--", registered_path.as_str()]); + + let remove_result = git_success(&control_workdir, &args); + match remove_result { + Err(error) => { + let still_registered = git_worktree_records_sync(&control_workdir) + .map(|records| { + records.iter().any(|record| { + worktree_paths_match(Path::new(&record.path), Path::new(®istered_path)) + }) + }) + .unwrap_or(true); + let worktree_removed = !still_registered; + let message = if worktree_removed { + format!("Worktree 登记已移除,但目录清理失败:{error}") + } else { + error.clone() + }; + Ok(GitRemoveWorktreeResponse { + ok: false, + state: git_status_sync(control_workdir)?, + worktree_path: registered_path, + main_worktree_path, + branch, + worktree_removed, + branch_delete_requested, + branch_deleted: false, + stdout: String::new(), + stderr: error, + message, + }) + } + Ok(remove_output) => { + let (ok, branch_deleted, stdout, stderr, message) = if branch_delete_requested + && !branch.is_empty() + { + match git_success(&control_workdir, &["branch", "-d", "--", branch.as_str()]) { + Ok(branch_output) => { + let stdout = [remove_output.stdout.as_str(), branch_output.stdout.as_str()] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n"); + ( + true, + true, + stdout, + branch_output.stderr, + "Worktree 与分支已删除。".to_string(), + ) + } + Err(error) => ( + false, + false, + remove_output.stdout, + error.clone(), + format!("Worktree 已移除,但分支删除失败:{error}"), + ), + } + } else if branch_delete_requested { + ( + true, + false, + remove_output.stdout, + remove_output.stderr, + "Worktree 已移除;该 Worktree 未检出本地分支。".to_string(), + ) + } else { + ( + true, + false, + remove_output.stdout, + remove_output.stderr, + "Worktree 已移除。".to_string(), + ) + }; + Ok(GitRemoveWorktreeResponse { + ok, + state: git_status_sync(control_workdir)?, + worktree_path: registered_path, + main_worktree_path, + branch, + worktree_removed: true, + branch_delete_requested, + branch_deleted, + stdout, + stderr, + message, + }) + } + } } pub(crate) fn git_rename_branch_sync( @@ -3039,6 +3531,16 @@ pub(crate) fn git_gateway_action_sync( args.branch.unwrap_or_default(), args.start_point, )?), + "create_worktree" => { + let legacy_name = args.name.unwrap_or_default(); + serde_json::to_value(git_create_worktree_sync( + workdir, + args.branch.unwrap_or_else(|| legacy_name.clone()), + args.directory_name.unwrap_or(legacy_name), + args.parent_directory, + args.start_point, + )?) + } "log" => serde_json::to_value(git_log_sync(workdir, args.limit, args.skip)?), "commit_details" => serde_json::to_value(git_commit_details_sync( workdir, @@ -3094,6 +3596,12 @@ pub(crate) fn git_gateway_action_sync( args.branch.unwrap_or_default(), args.new_branch.unwrap_or_default(), )?), + "remove_worktree" => serde_json::to_value(git_remove_worktree_sync( + workdir, + args.worktree_path.unwrap_or_default(), + args.force, + args.delete_branch, + )?), "stash_push" => serde_json::to_value(git_stash_push_sync(workdir, args.message)?), "stash_pop" => serde_json::to_value(git_stash_pop_sync(workdir)?), "" => return Err("Git action 不能为空。".to_string()), @@ -3177,6 +3685,28 @@ pub async fn git_create_branch( .map_err(|error| format!("git_create_branch join 失败:{error}"))? } +#[tauri::command(rename_all = "snake_case")] +pub async fn git_create_worktree( + workdir: String, + branch: Option, + directory_name: Option, + parent_directory: Option, + start_point: Option, + name: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let legacy_name = name.unwrap_or_default(); + git_create_worktree_sync( + workdir, + branch.unwrap_or_else(|| legacy_name.clone()), + directory_name.unwrap_or(legacy_name), + parent_directory, + start_point, + ) + }) + .await + .map_err(|error| format!("git_create_worktree join 失败:{error}"))? +} #[tauri::command(rename_all = "snake_case")] pub async fn git_init( workdir: String, @@ -3424,6 +3954,20 @@ pub async fn git_delete_branch( .map_err(|error| format!("git_delete_branch join 失败:{error}"))? } +#[tauri::command(rename_all = "snake_case")] +pub async fn git_remove_worktree( + workdir: String, + worktree_path: String, + force: Option, + delete_branch: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_remove_worktree_sync(workdir, worktree_path, force, delete_branch) + }) + .await + .map_err(|error| format!("git_remove_worktree join 失败:{error}"))? +} + #[tauri::command(rename_all = "snake_case")] pub async fn git_rename_branch( workdir: String, @@ -4550,6 +5094,548 @@ mod tests { assert_eq!(branch_head, initial_sha); } + #[test] + fn parse_git_worktree_records_handles_nul_fields_and_prunable_entries() { + let output = concat!( + "worktree /repo/main\0", + "HEAD abc\0", + "branch refs/heads/main\0\0", + "worktree /repo/linked path\0", + "HEAD def\0", + "branch refs/heads/feature/test\0", + "locked reason\0", + "prunable gitdir file points to non-existent location\0\0", + ); + let records = parse_git_worktree_records(output, "/repo/linked path"); + assert_eq!(records.len(), 2); + assert!(records[0].is_main); + assert!(!records[0].is_current); + assert_eq!(records[1].branch, "feature/test"); + assert!(records[1].is_current); + assert!(records[1].locked); + } + + #[test] + fn git_create_worktree_uses_liveagent_layout_and_checks_out_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let initial_branch = git_success(&workdir, &["branch", "--show-current"]) + .expect("read initial branch") + .stdout + .trim() + .to_string(); + + let created = git_create_worktree_in_base( + workdir.clone(), + "feature-alpha".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + assert!(created.ok, "create worktree failed: {}", created.message); + + // 路径布局:// + let repo_root = + fs::canonicalize(git_status_sync(workdir.clone()).expect("status").repo_root) + .expect("canonicalize repo root"); + let expected = fs::canonicalize( + worktree_root + .path() + .join(repo_worktree_id(&repo_root.to_string_lossy())) + .join("feature-alpha"), + ) + .expect("resolve expected worktree path"); + assert_eq!(PathBuf::from(&created.worktree_path), expected); + assert!(expected.is_dir(), "worktree directory should exist"); + + // 新 worktree 检出到同名新分支 + let branch = git_success(&created.worktree_path, &["branch", "--show-current"]) + .expect("branch of worktree"); + assert_eq!(branch.stdout.trim(), "feature-alpha"); + // 原仓库留在原分支 + assert_eq!(created.state.head, initial_branch); + } + + #[test] + fn git_create_worktree_supports_separate_branch_directory_and_custom_parent() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let default_root = tempfile::tempdir().expect("default worktree root"); + let custom_parent = tempfile::tempdir().expect("custom worktree parent"); + let created = git_create_worktree_with_base( + workdir.clone(), + "feature/custom-parent".to_string(), + "custom-folder".to_string(), + Some(custom_parent.path().to_string_lossy().into_owned()), + None, + Some(default_root.path()), + ) + .expect("create custom worktree"); + + assert!(created.ok, "create worktree failed: {}", created.message); + assert_eq!(created.branch, "feature/custom-parent"); + assert_eq!(created.directory_name, "custom-folder"); + assert_eq!( + PathBuf::from(&created.worktree_path), + fs::canonicalize(custom_parent.path().join("custom-folder")) + .expect("canonical custom worktree"), + ); + assert!( + !default_root + .path() + .join(repo_worktree_id(&created.main_worktree_path)) + .exists(), + "custom parent should bypass the managed default directory", + ); + let branch = git_success(&created.worktree_path, &["branch", "--show-current"]) + .expect("read custom worktree branch"); + assert_eq!(branch.stdout, "feature/custom-parent"); + } + + #[test] + fn validate_worktree_parent_directory_rejects_invalid_locations() { + let temp = tempfile::tempdir().expect("parent validation tempdir"); + let file = temp.path().join("file.txt"); + fs::write(&file, "file").expect("write validation file"); + assert!(validate_worktree_parent_directory("").is_err()); + assert!(validate_worktree_parent_directory("relative/path").is_err()); + assert!(validate_worktree_parent_directory(&file.to_string_lossy()).is_err()); + assert!( + validate_worktree_parent_directory(&temp.path().join("missing").to_string_lossy(),) + .is_err(), + ); + } + + #[test] + fn git_create_worktree_rejects_targets_inside_existing_worktrees() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let default_root = tempfile::tempdir().expect("default worktree root"); + let result = git_create_worktree_with_base( + workdir, + "feature/nested".to_string(), + "nested".to_string(), + Some(repo.path().to_string_lossy().into_owned()), + None, + Some(default_root.path()), + ); + assert!(result + .expect_err("nested worktree target must fail") + .contains("不能位于现有 Worktree 目录内"),); + } + + #[test] + fn git_create_worktree_can_start_from_another_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + + run_temp_git(repo.path(), &["checkout", "-b", "second"]); + fs::write(repo.path().join("second.txt"), "second\n").expect("write second file"); + run_temp_git(repo.path(), &["add", "second.txt"]); + run_temp_git(repo.path(), &["commit", "-m", "second"]); + run_temp_git(repo.path(), &["checkout", "-"]); + + let created = git_create_worktree_in_base( + workdir.clone(), + "from-second".to_string(), + Some("second".to_string()), + worktree_root.path(), + ) + .expect("create worktree from branch"); + assert!(created.ok, "create worktree failed: {}", created.message); + + let branch = git_success(&created.worktree_path, &["branch", "--show-current"]) + .expect("branch of worktree"); + assert_eq!(branch.stdout.trim(), "from-second"); + let log = git_success(&created.worktree_path, &["log", "--oneline", "-1"]) + .expect("worktree head"); + assert!( + log.stdout.contains("second"), + "worktree should start from second commit: {}", + log.stdout + ); + } + + #[test] + fn git_create_worktree_rejects_duplicate_name() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + + let first = git_create_worktree_in_base( + workdir.clone(), + "dup-alpha".to_string(), + None, + worktree_root.path(), + ) + .expect("first create"); + assert!(first.ok, "first create failed: {}", first.message); + + let second = git_create_worktree_in_base( + workdir.clone(), + "dup-alpha".to_string(), + None, + worktree_root.path(), + ); + assert!(second.is_err(), "duplicate worktree name must be rejected"); + } + + #[test] + fn git_create_worktree_rejects_invalid_names() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + + for invalid in ["", "a/b", "a\\b", "..", ".", "bad name", "HEAD"] { + let result = git_create_worktree_in_base( + workdir.clone(), + invalid.to_string(), + None, + worktree_root.path(), + ); + assert!(result.is_err(), "invalid name {invalid:?} must fail"); + } + } + + #[test] + fn git_worktrees_lists_created_worktree_branches() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-alpha".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + assert!(created.ok, "create worktree failed: {}", created.message); + + let state = git_status_sync(workdir.clone()).expect("status"); + let worktrees = git_worktrees_sync(&state.repo_root).expect("worktree list"); + let wt = worktrees + .iter() + .find(|info| info.path == created.worktree_path) + .expect("created worktree listed"); + assert_eq!(wt.branch, "wt-alpha"); + assert!(!wt.is_current); + assert_eq!( + normalized_worktree_path(Path::new(&wt.main_worktree_path)), + normalized_worktree_path(Path::new(&state.repo_root)), + ); + let linked_view = git_worktrees_sync(&created.worktree_path).expect("linked worktree list"); + let current = linked_view + .iter() + .find(|info| info.path == created.worktree_path) + .expect("current linked worktree listed"); + assert!(current.is_current); + } + #[test] + fn git_worktrees_excludes_main_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let state = git_status_sync(workdir.clone()).expect("status"); + let worktrees = git_worktrees_sync(&state.repo_root).expect("worktree list"); + let main = fs::canonicalize(&state.repo_root).expect("canonicalize main"); + assert!( + !worktrees.iter().any(|info| { + fs::canonicalize(&info.path) + .map(|path| path == main) + .unwrap_or(false) + }), + "main worktree must not be listed as a linked worktree: {:#?}", + worktrees + ); + } + + #[test] + fn git_remove_worktree_removes_worktree_and_branch() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-remove".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + assert!(created.ok, "create worktree failed: {}", created.message); + + let removed = git_remove_worktree_sync( + workdir.clone(), + created.worktree_path.clone(), + None, + Some(true), + ) + .expect("remove worktree"); + assert!(removed.ok, "remove worktree failed: {}", removed.message); + assert!(removed.worktree_removed); + assert!(removed.branch_deleted); + assert_eq!(removed.branch, "wt-remove"); + assert!( + !PathBuf::from(&created.worktree_path).exists(), + "worktree directory should be gone" + ); + + // 分支应随之删除 + let state = git_status_sync(workdir.clone()).expect("status"); + let worktrees = git_worktrees_sync(&state.repo_root).expect("worktree list"); + assert!( + !worktrees + .iter() + .any(|info| info.path == created.worktree_path), + "worktree should be unregistered" + ); + let branches = git_branches_sync(workdir.clone()).expect("branches"); + assert!( + !branches + .branches + .iter() + .any(|branch| branch.full_name == "wt-remove"), + "branch should be deleted with the worktree" + ); + } + + #[test] + fn git_remove_worktree_reports_unmerged_branch_after_removing_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-unmerged".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + let worktree_path = PathBuf::from(&created.worktree_path); + fs::write(worktree_path.join("unmerged.txt"), "unmerged\n").expect("write worktree file"); + run_temp_git(&worktree_path, &["add", "unmerged.txt"]); + run_temp_git( + &worktree_path, + &["commit", "-m", "unmerged worktree commit"], + ); + + let result = git_remove_worktree_sync( + workdir.clone(), + created.worktree_path.clone(), + None, + Some(true), + ) + .expect("worktree removal should return an operation response"); + + assert!( + !result.ok, + "unmerged branch deletion should report an error" + ); + assert!(result.worktree_removed); + assert!(!result.branch_deleted); + assert_eq!(result.branch, "wt-unmerged"); + assert!( + result.message.contains("Worktree 已移除,但分支删除失败") + && result.message.contains("not fully merged"), + "unexpected removal error: {}", + result.message + ); + assert!( + !worktree_path.exists(), + "worktree should already be removed" + ); + let branches = git_branches_sync(workdir).expect("branches"); + assert!( + branches + .branches + .iter() + .any(|branch| branch.full_name == "wt-unmerged"), + "the unmerged branch should remain available for force deletion" + ); + } + + #[test] + fn git_remove_worktree_force_preserves_unmerged_branch_for_confirmation() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + workdir.clone(), + "wt-force-unmerged".to_string(), + None, + worktree_root.path(), + ) + .expect("create worktree"); + let worktree_path = PathBuf::from(&created.worktree_path); + fs::write(worktree_path.join("unmerged.txt"), "unmerged\n").expect("write worktree file"); + run_temp_git(&worktree_path, &["add", "unmerged.txt"]); + run_temp_git( + &worktree_path, + &["commit", "-m", "unmerged worktree commit"], + ); + + let result = git_remove_worktree_sync( + workdir.clone(), + created.worktree_path.clone(), + Some(true), + Some(true), + ) + .expect("force remove worktree"); + + assert!( + !result.ok, + "force removal must not delete an unmerged branch" + ); + assert!( + result.message.contains("Worktree 已移除,但分支删除失败") + && result.message.contains("not fully merged"), + "unexpected force removal error: {}", + result.message + ); + assert!(!worktree_path.exists(), "worktree should be removed"); + let branches = git_branches_sync(workdir).expect("branches"); + assert!( + branches + .branches + .iter() + .any(|branch| branch.full_name == "wt-force-unmerged"), + "force removal should preserve the unmerged branch for confirmation" + ); + } + + #[test] + fn git_remove_worktree_preserves_non_target_control_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let main_workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let control = git_create_worktree_in_base( + main_workdir.clone(), + "wt-control-a".to_string(), + None, + worktree_root.path(), + ) + .expect("create control worktree"); + let target = git_create_worktree_in_base( + main_workdir.clone(), + "wt-control-b".to_string(), + None, + worktree_root.path(), + ) + .expect("create target worktree"); + + let removed = git_remove_worktree_sync( + control.worktree_path.clone(), + target.worktree_path.clone(), + None, + Some(false), + ) + .expect("remove non-current worktree"); + assert!(removed.ok, "remove target failed: {}", removed.message); + assert_eq!( + normalized_worktree_path(Path::new(&removed.state.repo_root)), + normalized_worktree_path(Path::new(&control.worktree_path)), + ); + assert!(Path::new(&control.worktree_path).is_dir()); + + let deleted_target_branch = git_delete_branch_sync( + control.worktree_path.clone(), + "wt-control-b".to_string(), + Some(true), + ) + .expect("delete target branch"); + assert!(deleted_target_branch.ok); + let cleanup = + git_remove_worktree_sync(main_workdir, control.worktree_path, None, Some(true)) + .expect("cleanup control worktree"); + assert!(cleanup.ok, "cleanup failed: {}", cleanup.message); + } + + #[test] + fn git_remove_worktree_can_remove_the_current_linked_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let main_workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let created = git_create_worktree_in_base( + main_workdir.clone(), + "wt-current".to_string(), + None, + worktree_root.path(), + ) + .expect("create current worktree"); + + let removed = git_remove_worktree_sync( + created.worktree_path.clone(), + created.worktree_path.clone(), + None, + Some(true), + ) + .expect("remove current linked worktree"); + assert!(removed.ok, "self removal failed: {}", removed.message); + assert!(removed.worktree_removed); + assert!(removed.branch_deleted); + assert_eq!( + normalized_worktree_path(Path::new(&removed.state.repo_root)), + normalized_worktree_path(repo.path()), + ); + } + + #[test] + fn git_remove_worktree_rejects_main_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let result = git_remove_worktree_sync(workdir.clone(), workdir, None, Some(false)); + assert!(result + .expect_err("main worktree removal must fail") + .contains("不能删除主 Worktree"),); + } + + #[test] + fn git_remove_worktree_rejects_unregistered_path() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let outside = tempfile::tempdir().expect("outside dir"); + let result = git_remove_worktree_sync( + workdir.clone(), + outside.path().to_string_lossy().to_string(), + None, + None, + ); + assert!( + result.is_err(), + "unregistered worktree path must be rejected" + ); + } + #[test] fn git_compare_commit_with_remote_uses_origin_fallback() { let Some(repo) = init_temp_repo() else { @@ -5024,6 +6110,114 @@ mod tests { ); } + #[test] + fn git_delete_branch_prunes_manually_deleted_worktree() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let worktree_path = worktree_root.path().join("stale-worktree"); + let worktree_path_str = worktree_path.to_string_lossy().into_owned(); + + run_temp_git( + repo.path(), + &[ + "worktree", + "add", + "-b", + "stale-worktree", + worktree_path_str.as_str(), + ], + ); + fs::remove_dir_all(&worktree_path).expect("manually delete worktree directory"); + + let stale_list = git_success( + repo.path().to_string_lossy().as_ref(), + &["worktree", "list", "--porcelain"], + ) + .expect("list stale worktrees"); + assert!( + stale_list.stdout.contains("refs/heads/stale-worktree"), + "stale worktree registration should exist before delete: {}", + stale_list.stdout + ); + + let deleted = git_delete_branch_sync(workdir, "stale-worktree".to_string(), None) + .expect("delete stale worktree branch"); + assert!( + deleted.ok, + "delete should prune stale worktree: {}", + deleted.message + ); + assert!( + !ref_exists( + repo.path().to_string_lossy().as_ref(), + "refs/heads/stale-worktree" + ), + "stale worktree branch should be deleted" + ); + + let pruned_list = git_success( + repo.path().to_string_lossy().as_ref(), + &["worktree", "list", "--porcelain"], + ) + .expect("list pruned worktrees"); + assert!( + !pruned_list.stdout.contains("refs/heads/stale-worktree"), + "stale worktree registration should be pruned: {}", + pruned_list.stdout + ); + } + + #[test] + fn git_delete_branch_keeps_existing_worktree_protected_when_forced() { + let Some(repo) = init_temp_repo() else { + return; + }; + let workdir = repo.path().to_string_lossy().to_string(); + let worktree_root = tempfile::tempdir().expect("worktree root"); + let worktree_path = worktree_root.path().join("active-worktree"); + let worktree_path_str = worktree_path.to_string_lossy().into_owned(); + + run_temp_git( + repo.path(), + &[ + "worktree", + "add", + "-b", + "active-worktree", + worktree_path_str.as_str(), + ], + ); + + let refused = git_delete_branch_sync(workdir, "active-worktree".to_string(), Some(true)) + .expect("force delete active worktree branch"); + assert!(!refused.ok, "active worktree branch must remain protected"); + assert!( + worktree_path.is_dir(), + "active worktree directory should remain" + ); + assert!( + ref_exists( + repo.path().to_string_lossy().as_ref(), + "refs/heads/active-worktree" + ), + "active worktree branch should remain" + ); + + let worktree_list = git_success( + repo.path().to_string_lossy().as_ref(), + &["worktree", "list", "--porcelain"], + ) + .expect("list active worktrees"); + assert!( + worktree_list.stdout.contains("refs/heads/active-worktree"), + "active worktree registration should remain: {}", + worktree_list.stdout + ); + } + #[test] fn git_rename_branch_renames_local_and_current_branch() { let Some(repo) = init_temp_repo() else { diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index bc80581c1..b19e1f6bb 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -217,6 +217,8 @@ macro_rules! app_invoke_handler { commands::git::git_list_remote_branches, commands::git::git_switch_branch, commands::git::git_create_branch, + commands::git::git_create_worktree, + commands::git::git_remove_worktree, commands::git::git_diff, commands::git::git_log, commands::git::git_commit_details, diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 59507d52a..7bf218229 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -55,6 +55,18 @@ export const translations: Record> = { "chat.recentConversation": "最近会话", "chat.workspaceSection": "工作空间", "chat.workspaceCreate": "新建工作空间", + "chat.workspaceAdd": "添加…", + "chat.workspaceUngrouped": "未分组", + "chat.workspaceGroupCreate": "新建分组", + "chat.workspaceGroupNamePlaceholder": "分组名称", + "chat.workspaceGroupRename": "重命名分组", + "chat.workspaceGroupDelete": "删除分组", + "chat.workspaceGroupDeleteConfirmTitle": "删除分组「{name}」?", + "chat.workspaceGroupDeleteConfirmDescription": "组内项目将回到未分组,项目本身不会被删除。", + "chat.workspaceGroupToggle": "展开/折叠分组", + "chat.workspaceGroupActions": "分组操作", + "chat.workspaceGroupMove": "移动到分组", + "chat.workspaceGroupUngroup": "移出分组", "chat.workspaceCreateDescription": "打开已有文件夹,或从远程 Git 仓库创建新的工作空间。", "chat.workspaceOpenFolder": "打开本地文件夹", "chat.workspaceOpenFolderDescription": "将已有文件夹添加为工作空间。", @@ -101,6 +113,7 @@ export const translations: Record> = { "chat.workspaceUnpin": "取消置顶", "chat.workspaceRename": "修改标题", "chat.workspaceRemove": "移除工作空间", + "chat.workspaceRemoveOnly": "移除工作空间", "chat.workspaceArchive": "归档", "chat.workspaceUnarchive": "取消归档", "chat.workspaceArchivedGroup": "已归档({count})", @@ -112,7 +125,7 @@ export const translations: Record> = { "chat.workspaceShowAllProjects": "显示全部({count})", "chat.workspaceShowLessProjects": "收起", "chat.workspaceRemoveRunning": "后台任务运行中,暂时不能移除。", - "chat.workspaceRemoveDescription": "会删除此工作空间下的历史对话,不会删除文件夹。", + "chat.workspaceRemoveDescription": "只从侧边栏移除该工作空间,历史对话与文件夹都会保留。", "chat.workspaceOpenSystemFileManagerFailed": "打开资源管理器失败", "chat.exitConfirmTitle": "退出 LiveAgent?", "chat.exitConfirmSubtitle": "当前仍有终端任务在运行。", @@ -121,9 +134,16 @@ export const translations: Record> = { "chat.exitConfirmNote": "不会删除项目或会话历史,但正在运行的命令会被终止。", "chat.exitConfirmContinue": "继续退出", "chat.exitConfirmClose": "关闭退出确认", - "chat.workspaceRemoveTerminalDescription": "删除项目会关闭这些 Terminal 进程。", - "chat.workspaceRemoveConfirmContinue": "删除项目", - "chat.workspaceRemoveConfirmClose": "关闭删除项目确认", + "chat.workspaceDeleteWorktree": "删除 Worktree", + "chat.workspaceDeleteWorktreeConfirm": "删除 Worktree「{name}」?", + "chat.workspaceDeleteWorktreeDescription": "会删除磁盘上的 Worktree 目录,历史对话保留。", + "chat.workspaceDeleteWorktreeBranch": "同时删除分支「{branch}」", + "chat.workspaceDeleteWorktreeTerminalDescription": "删除 Worktree 会关闭这些 Terminal 进程。", + "chat.workspaceDeleteWorktreeConfirmClose": "关闭删除 Worktree 确认", + "chat.workspaceDeleteWorktreeMetadataMissing": + "缺少 Worktree 信息,无法删除;请改用「移除工作空间」。", + "chat.workspaceDeleteWorktreeUnavailable": "当前环境不支持删除 Worktree。", + "chat.workspaceDeleteFailed": "删除 Worktree 失败", "chat.conversationMore": "更多操作", "chat.conversationPin": "置顶对话", "chat.conversationUnpin": "取消置顶", @@ -789,9 +809,39 @@ export const translations: Record> = { "git.branchSelector.deleteForceTitle": "分支尚未完全合并", "git.branchSelector.deleteForceDescription": "强制删除(-D)会丢弃仅存在于该分支上的提交。", "git.branchSelector.forceDelete": "强制删除", + "git.branchSelector.deleteWorktree": "删除 Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": "删除 Worktree「{path}」?", + "git.branchSelector.deleteWorktreeConfirmDescription": + "将删除磁盘上的 Worktree 目录及其在仓库中的登记。", + "git.branchSelector.deleteWorktreeBranchTitle": "同时删除分支「{branch}」?", + "git.branchSelector.deleteWorktreeBranchDescription": + "可以连同 Worktree 一起删除该分支,也可以先保留分支稍后处理。", + "git.branchSelector.deleteWorktreeAndBranch": "删除 Worktree 和分支", + "git.branchSelector.keepWorktreeBranch": "保留分支", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree 包含未提交改动", + "git.branchSelector.deleteWorktreeForceDescription": + "强制移除(--force)会丢弃 Worktree 中的未提交改动。", + "git.branchSelector.forceRemoveWorktree": "强制移除", "git.branchSelector.moreActions": "更多操作", "git.branchSelector.stashPush": "暂存当前改动 (stash)", "git.branchSelector.stashPop": "恢复最近的 stash", + "git.branchSelector.createWorktree": "新建 Worktree", + "git.branchSelector.createWorktreeTitle": "新建 Worktree", + "git.branchSelector.worktreeDescription": "在独立目录检出仓库副本,可并行开发多个分支。", + "git.branchSelector.worktreeStartPoint": "基于分支", + "git.branchSelector.worktreeBranch": "新分支名", + "git.branchSelector.worktreeBranchPlaceholder": "feature/my-branch", + "git.branchSelector.worktreeDirectoryName": "目录名", + "git.branchSelector.worktreeDirectoryPlaceholder": "feature-my-branch", + "git.branchSelector.worktreeParentDirectory": "保存位置", + "git.branchSelector.worktreeDefaultLocation": "默认位置(~/.liveagent/worktree)", + "git.branchSelector.worktreeUseDefaultLocation": "恢复默认位置", + "git.branchSelector.worktreeChooseParent": "选择…", + "git.branchSelector.worktreeLocationHint": + "将保存到 ~/.liveagent/worktree 下的独立目录,创建后自动在侧边栏打开。", + "git.branchSelector.worktreeCustomLocationHint": + "将在所选目录下创建 Worktree 文件夹,创建后自动在侧边栏打开。", + "git.branchSelector.worktreeFailed": "创建 Worktree 失败", "projectTools.reorderTab": "调整标签排序", "projectTools.reorderTabHint": "拖动排序,或聚焦后按左右方向键移动", "projectTools.shell": "Shell", @@ -2342,6 +2392,19 @@ export const translations: Record> = { "chat.recentConversation": "Conversations", "chat.workspaceSection": "Workspaces", "chat.workspaceCreate": "New workspace", + "chat.workspaceAdd": "Add…", + "chat.workspaceUngrouped": "Ungrouped", + "chat.workspaceGroupCreate": "New Group", + "chat.workspaceGroupNamePlaceholder": "Group name", + "chat.workspaceGroupRename": "Rename group", + "chat.workspaceGroupDelete": "Delete group", + "chat.workspaceGroupDeleteConfirmTitle": 'Delete group "{name}"?', + "chat.workspaceGroupDeleteConfirmDescription": + "Projects in the group move back to ungrouped; the projects themselves are not deleted.", + "chat.workspaceGroupToggle": "Toggle group", + "chat.workspaceGroupActions": "Group actions", + "chat.workspaceGroupMove": "Move to group", + "chat.workspaceGroupUngroup": "Ungroup", "chat.workspaceCreateDescription": "Open an existing folder or create a new workspace from a remote Git repository.", "chat.workspaceOpenFolder": "Open local folder", @@ -2393,6 +2456,7 @@ export const translations: Record> = { "chat.workspaceUnpin": "Unpin", "chat.workspaceRename": "Rename", "chat.workspaceRemove": "Remove workspace", + "chat.workspaceRemoveOnly": "Remove workspace", "chat.workspaceArchive": "Archive", "chat.workspaceUnarchive": "Unarchive", "chat.workspaceArchivedGroup": "Archived ({count})", @@ -2406,7 +2470,7 @@ export const translations: Record> = { "chat.workspaceRemoveRunning": "A background task is running, so this workspace cannot be removed yet.", "chat.workspaceRemoveDescription": - "This deletes conversations under the workspace, but it does not delete the folder.", + "Removes this workspace from the sidebar; conversations and the folder are kept.", "chat.workspaceOpenSystemFileManagerFailed": "Failed to open the file manager", "chat.exitConfirmTitle": "Exit LiveAgent?", "chat.exitConfirmSubtitle": "Terminal tasks are still running.", @@ -2417,10 +2481,18 @@ export const translations: Record> = { "Projects and conversation history will not be deleted, but running commands will be stopped.", "chat.exitConfirmContinue": "Exit anyway", "chat.exitConfirmClose": "Close exit confirmation", - "chat.workspaceRemoveTerminalDescription": - "Deleting the project will close these Terminal processes.", - "chat.workspaceRemoveConfirmContinue": "Delete project", - "chat.workspaceRemoveConfirmClose": "Close project deletion confirmation", + "chat.workspaceDeleteWorktree": "Delete Worktree", + "chat.workspaceDeleteWorktreeConfirm": 'Delete worktree "{name}"?', + "chat.workspaceDeleteWorktreeDescription": + "Deletes the worktree directory on disk; conversations are kept.", + "chat.workspaceDeleteWorktreeBranch": 'Also delete branch "{branch}"', + "chat.workspaceDeleteWorktreeTerminalDescription": + "Deleting the worktree will close these Terminal processes.", + "chat.workspaceDeleteWorktreeConfirmClose": "Close worktree deletion confirmation", + "chat.workspaceDeleteWorktreeMetadataMissing": + "Worktree metadata is missing; use Remove workspace instead.", + "chat.workspaceDeleteWorktreeUnavailable": "Deleting worktrees is not available here.", + "chat.workspaceDeleteFailed": "Failed to delete worktree", "chat.conversationMore": "More actions", "chat.conversationPin": "Pin conversation", "chat.conversationUnpin": "Unpin", @@ -3114,9 +3186,40 @@ export const translations: Record> = { "git.branchSelector.deleteForceDescription": "Force delete (-D) discards commits that only exist on this branch.", "git.branchSelector.forceDelete": "Force delete", + "git.branchSelector.deleteWorktree": "Delete Worktree", + "git.branchSelector.deleteWorktreeConfirmTitle": 'Delete worktree "{path}"?', + "git.branchSelector.deleteWorktreeConfirmDescription": + "Deletes the worktree directory on disk and unregisters it from the repository.", + "git.branchSelector.deleteWorktreeBranchTitle": 'Also delete branch "{branch}"?', + "git.branchSelector.deleteWorktreeBranchDescription": + "Delete the branch together with the worktree, or keep it for later.", + "git.branchSelector.deleteWorktreeAndBranch": "Delete worktree and branch", + "git.branchSelector.keepWorktreeBranch": "Keep branch", + "git.branchSelector.deleteWorktreeForceTitle": "Worktree contains uncommitted changes", + "git.branchSelector.deleteWorktreeForceDescription": + "Force removal (--force) discards uncommitted changes in the worktree.", + "git.branchSelector.forceRemoveWorktree": "Force remove", "git.branchSelector.moreActions": "More actions", "git.branchSelector.stashPush": "Stash changes", "git.branchSelector.stashPop": "Pop latest stash", + "git.branchSelector.createWorktree": "Create Worktree", + "git.branchSelector.createWorktreeTitle": "Create Worktree", + "git.branchSelector.worktreeDescription": + "Check out a separate copy of the repository to work on multiple branches in parallel.", + "git.branchSelector.worktreeStartPoint": "Start point", + "git.branchSelector.worktreeBranch": "New branch", + "git.branchSelector.worktreeBranchPlaceholder": "feature/my-branch", + "git.branchSelector.worktreeDirectoryName": "Directory name", + "git.branchSelector.worktreeDirectoryPlaceholder": "feature-my-branch", + "git.branchSelector.worktreeParentDirectory": "Location", + "git.branchSelector.worktreeDefaultLocation": "Default location (~/.liveagent/worktree)", + "git.branchSelector.worktreeUseDefaultLocation": "Use default location", + "git.branchSelector.worktreeChooseParent": "Choose…", + "git.branchSelector.worktreeLocationHint": + "Saved under ~/.liveagent/worktree in a separate directory and opened in the sidebar automatically.", + "git.branchSelector.worktreeCustomLocationHint": + "The worktree folder is created inside the chosen directory and opened in the sidebar automatically.", + "git.branchSelector.worktreeFailed": "Failed to create worktree", "projectTools.reorderTab": "Reorder tab", "projectTools.reorderTabHint": "Drag to reorder, or focus and use Left/Right", "projectTools.shell": "Shell", diff --git a/crates/agent-gui/src/lib/git/tauriGitClient.ts b/crates/agent-gui/src/lib/git/tauriGitClient.ts index 5cf43cec0..c11894dc1 100644 --- a/crates/agent-gui/src/lib/git/tauriGitClient.ts +++ b/crates/agent-gui/src/lib/git/tauriGitClient.ts @@ -5,8 +5,10 @@ import { normalizeGitDiffResponse, normalizeGitLogResponse, normalizeGitOperationResponse, + normalizeGitRemoveWorktreeResponse, normalizeGitRepositoryDiscovery, normalizeGitRepositoryState, + normalizeGitWorktreeResponse, } from "@liveagent/ui/lib/git/types"; import { invoke } from "@tauri-apps/api/core"; @@ -132,6 +134,29 @@ export const tauriGitClient: GitClient = { workdir, ); }, + async createWorktree(workdir, options) { + return normalizeGitWorktreeResponse( + await invoke("git_create_worktree", { + workdir, + branch: options.branch, + directory_name: options.directoryName, + parent_directory: options.parentDirectory, + start_point: options.startPoint, + }), + workdir, + ); + }, + async removeWorktree(workdir, worktreePath, options = {}) { + return normalizeGitRemoveWorktreeResponse( + await invoke("git_remove_worktree", { + workdir, + worktree_path: worktreePath, + force: options.force, + delete_branch: options.deleteBranch, + }), + workdir, + ); + }, async stashPush(workdir, message) { return normalizeGitOperationResponse( await invoke("git_stash_push", { workdir, message }), diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 5df702d3f..a58ea18ee 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -22,6 +22,7 @@ import { MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRANSCRIPT_WIDTH, } from "@liveagent/ui/lib/transcript-width/transcriptWidthModel"; +import type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; import { DEFAULT_LOCALE, type Locale, normalizeLocale } from "../../i18n/config"; import { ANTHROPIC_LONG_CONTEXT_WINDOW, @@ -34,6 +35,7 @@ import { } from "../providers/anthropicModels"; import { normalizeFontFamily } from "../system/fontFamily"; +export type { WorkspaceProjectGroup } from "@liveagent/ui/lib/workspaceProjectTypes"; export { normalizeFontFamily } from "../system/fontFamily"; export function isThinkingAlwaysOnForModel( @@ -265,6 +267,7 @@ export type SystemSettings = { */ toolPolicies?: Record; workspaceProjects: WorkspaceProject[]; + workspaceProjectGroups: WorkspaceProjectGroup[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; missingWorkspaceProjectPaths: string[]; @@ -301,6 +304,10 @@ export type WorkspaceProject = { name: string; path: string; kind: WorkspaceProjectKind; + worktree?: { + repositoryPath: string; + branch?: string; + }; createdAt: number; updatedAt: number; lastConversationAt?: number; @@ -828,6 +835,20 @@ function normalizeWorkspaceProjectKind(input: unknown): WorkspaceProjectKind { } } +function normalizeWorkspaceProjectWorktree( + input: unknown, +): WorkspaceProject["worktree"] | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; + const obj = input as Record; + const repositoryPath = normalizeWorkspaceProjectPath(obj.repositoryPath); + if (!repositoryPath) return undefined; + const branch = typeof obj.branch === "string" ? obj.branch.trim() : ""; + return { + repositoryPath, + ...(branch ? { branch } : {}), + }; +} + function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null { const obj = (input && typeof input === "object" ? input : {}) as Record; const path = normalizeWorkspaceProjectPath(obj.path); @@ -859,11 +880,13 @@ function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null { typeof obj.pinnedAt === "number" && Number.isFinite(obj.pinnedAt) && obj.pinnedAt > 0 ? obj.pinnedAt : undefined; + const worktree = normalizeWorkspaceProjectWorktree(obj.worktree); return { id, name, path, kind: normalizeWorkspaceProjectKind(obj.kind), + ...(worktree ? { worktree } : {}), createdAt, updatedAt, ...(lastConversationAt ? { lastConversationAt } : {}), @@ -871,6 +894,54 @@ function normalizeWorkspaceProject(input: unknown): WorkspaceProject | null { }; } +function normalizeWorkspaceProjectGroups(input: unknown): WorkspaceProjectGroup[] { + if (!Array.isArray(input)) return []; + const out: WorkspaceProjectGroup[] = []; + const seenIds = new Set(); + for (const raw of input) { + const group = normalizeWorkspaceProjectGroup(raw); + if (!group) continue; + if (seenIds.has(group.id)) continue; + seenIds.add(group.id); + out.push(group); + } + return out; +} + +function normalizeWorkspaceProjectGroup(input: unknown): WorkspaceProjectGroup | null { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(); + const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : "未命名分组"; + const projectPaths: string[] = []; + const seenPaths = new Set(); + for (const raw of normalizeStringArray(obj.projectPaths)) { + const path = normalizeWorkspaceProjectPath(raw); + if (!path) continue; + const key = workspaceProjectPathKey(path); + if (seenPaths.has(key)) continue; + seenPaths.add(key); + projectPaths.push(path); + } + const sourceProjectPath = normalizeWorkspaceProjectPath(obj.sourceProjectPath); + const createdAt = + typeof obj.createdAt === "number" && Number.isFinite(obj.createdAt) && obj.createdAt > 0 + ? obj.createdAt + : Date.now(); + const updatedAt = + typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) && obj.updatedAt > 0 + ? obj.updatedAt + : createdAt; + return { + id, + name, + projectPaths, + ...(sourceProjectPath ? { sourceProjectPath } : {}), + ...(obj.collapsed === true ? { collapsed: true } : {}), + createdAt, + updatedAt, + }; +} + function normalizeWorkspaceProjects(input: unknown): WorkspaceProject[] { if (!Array.isArray(input)) return []; const out: WorkspaceProject[] = []; @@ -950,6 +1021,7 @@ export function resolveWorkspaceProjects( kind: "managed", createdAt: defaultExisting?.createdAt ?? now, updatedAt: defaultExisting?.updatedAt ?? now, + ...(defaultExisting?.worktree ? { worktree: defaultExisting.worktree } : {}), ...(defaultExisting?.lastConversationAt ? { lastConversationAt: defaultExisting.lastConversationAt } : {}), @@ -1807,6 +1879,7 @@ export function normalizeSystemSettings(input: unknown): SystemSettings { workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), + workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() ? obj.activeWorkspaceProjectId.trim() @@ -2467,6 +2540,7 @@ export function getDefaultSettings(): AppSettings { executionMode: "tools", workdir: "", workspaceProjects: [], + workspaceProjectGroups: [], activeWorkspaceProjectId: undefined, hiddenWorkspaceProjectPaths: [], missingWorkspaceProjectPaths: [], diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index d7b6c9469..aa0fb1b13 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -316,6 +316,13 @@ export function ChatPage(props: ChatPageProps) { handleOpenWorkspaceFolder, handleCloneWorkspaceProject, handleOpenClonedWorkspace, + handleOpenWorktree, + workspaceProjectGroups, + handleCreateWorkspaceGroup, + handleRenameWorkspaceGroup, + handleDeleteWorkspaceGroup, + handleMoveWorkspaceProjectToGroup, + handleToggleWorkspaceGroupCollapsed, handleLoadWorkspaceRemoteBranches, handleStartRenamingWorkspaceProject, handleCommitWorkspaceProjectRename, @@ -1200,6 +1207,7 @@ export function ChatPage(props: ChatPageProps) { cleanupDeletedConversationActionRef.current = cleanupDeletedConversation; const { + removeWorkspaceProjectFromSettings, handleRemoveWorkspaceProject, handleArchiveWorkspaceProject, handleUnarchiveWorkspaceProject, @@ -1217,16 +1225,6 @@ export function ChatPage(props: ChatPageProps) { setActiveWorkspaceProjectId, setProjectRenamingId, setProjectRenameDraft, - isConversationRunning, - currentConversationIdRef, - conversationRuntimeCacheRef, - conversationPersistenceCursorRef, - locallySyncedHistoryUpdatedAtRef, - deleteConversationLocalCaches, - disposeSubagentsForConversation: (conversationId) => { - subagentStoresRef.current.dispose(conversationId); - }, - removeSharedHistoryItems, terminalProjectPathKey, setTerminalSessions, setRightDockOpen, @@ -1234,6 +1232,18 @@ export function ChatPage(props: ChatPageProps) { startNewConversationActionRef, }); + const handleWorktreeRemoved = useCallback( + (worktree: { path: string }) => { + const pathKey = workspaceProjectPathKey(worktree.path); + if (!pathKey) return; + const project = workspaceProjects.find( + (item) => workspaceProjectPathKey(item.path) === pathKey, + ); + if (project) removeWorkspaceProjectFromSettings(project); + }, + [removeWorkspaceProjectFromSettings, workspaceProjects], + ); + useEffect(() => { const nextWorkdir = activeWorkspaceProjectPath.trim(); if (!isAgentMode || !nextWorkdir) { @@ -1984,6 +1994,7 @@ export function ChatPage(props: ChatPageProps) { activeView={activeView} showProjects={isAgentMode} projects={workspaceProjects} + workspaceProjectGroups={workspaceProjectGroups} activeProjectId={activeWorkspaceProject?.id} missingProjectPathKeys={missingWorkspaceProjectPathKeys} projectRenamingId={projectRenamingId} @@ -1993,6 +2004,11 @@ export function ChatPage(props: ChatPageProps) { onProjectsCollapsedChange={handleSidebarProjectsCollapsedChange} onRecentCollapsedChange={handleSidebarRecentCollapsedChange} onCreateProject={handleOpenCreateWorkspaceProject} + onCreateWorkspaceGroup={handleCreateWorkspaceGroup} + onRenameWorkspaceGroup={handleRenameWorkspaceGroup} + onDeleteWorkspaceGroup={handleDeleteWorkspaceGroup} + onMoveProjectToGroup={handleMoveWorkspaceProjectToGroup} + onToggleWorkspaceGroupCollapsed={handleToggleWorkspaceGroupCollapsed} onSelectProject={handleSelectWorkspaceProject} onNewConversationForProject={handleNewConversationForProject} onBrowseProjectInFileTree={handleBrowseWorkspaceProjectInFileTree} @@ -2210,6 +2226,8 @@ export function ChatPage(props: ChatPageProps) { manualCompactBlocked={isCompactionRunning} gitClient={tauriGitClient} workspaceActivityClient={tauriWorkspaceActivityClient} + onOpenWorktree={handleOpenWorktree} + onWorktreeRemoved={handleWorktreeRemoved} onSend={handleSend} onStop={handleStopSending} onComposerBusyChange={handleComposerBusyChange} diff --git a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx index 3ad8cf280..af1cbefff 100644 --- a/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx +++ b/crates/agent-gui/src/pages/chat/sidebar/ChatSidebarContainer.tsx @@ -3,7 +3,10 @@ // ChatPage), the conversation-rename UI state, the delete flow, and the // error-code → i18n mapping. NOT mirrored — the web end has its own container. -import { ChatHistorySidebar } from "@liveagent/ui/components/chat/ChatHistorySidebar"; +import { + ChatHistorySidebar, + type WorkspaceProjectRemoveOptions, +} from "@liveagent/ui/components/chat/ChatHistorySidebar"; import { useLocale } from "@liveagent/ui/i18n/index"; import type { SidebarBatchDeleteOptions } from "@liveagent/ui/lib/sidebar/batchDelete"; import { deleteSidebarConversations } from "@liveagent/ui/lib/sidebar/batchDelete"; @@ -27,7 +30,7 @@ import { } from "../../../agent-ui-adapters/sidebarChrome"; import type { AppUpdateController } from "../../../lib/appUpdates"; import { normalizeConversationTitle } from "../../../lib/chat/page/chatPageHelpers"; -import type { WorkspaceProject } from "../../../lib/settings"; +import type { WorkspaceProject, WorkspaceProjectGroup } from "../../../lib/settings"; import { moveConversationsToWorkspace, moveConversationToWorkspace, @@ -43,6 +46,7 @@ type ChatSidebarContainerProps = { // Merged (settings ∪ history workdirs) but unsorted — the container sorts // with the store's activity/running inputs. projects: WorkspaceProject[]; + workspaceProjectGroups: WorkspaceProjectGroup[]; activeProjectId?: string; missingProjectPathKeys: ReadonlySet; projectRenamingId: string | null; @@ -52,6 +56,11 @@ type ChatSidebarContainerProps = { onProjectsCollapsedChange: (collapsed: boolean) => void; onRecentCollapsedChange: (collapsed: boolean) => void; onCreateProject: () => void; + onCreateWorkspaceGroup: (name: string) => void; + onRenameWorkspaceGroup: (groupId: string, name: string) => void; + onDeleteWorkspaceGroup: (groupId: string) => void; + onMoveProjectToGroup: (projectPath: string, groupId: string | null) => void; + onToggleWorkspaceGroupCollapsed: (groupId: string) => void; onSelectProject: (project: WorkspaceProject) => void; onNewConversationForProject: (project: WorkspaceProject) => void; onBrowseProjectInFileTree: (project: WorkspaceProject) => void; @@ -62,7 +71,7 @@ type ChatSidebarContainerProps = { onCommitProjectRename: () => void; onCancelProjectRename: () => void; onSetProjectPinned: (project: WorkspaceProject, isPinned: boolean) => void; - onRemoveProject: (project: WorkspaceProject) => void; + onRemoveProject: (project: WorkspaceProject, options?: WorkspaceProjectRemoveOptions) => void; onArchiveProject: (project: WorkspaceProject) => void; onUnarchiveProject: (project: WorkspaceProject) => void; archivedProjectPathKeys?: ReadonlySet; @@ -239,6 +248,7 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { activeView={props.activeView} showProjects={props.showProjects} projects={sortedProjects} + workspaceProjectGroups={props.workspaceProjectGroups} activeProjectId={props.activeProjectId} missingProjectPathKeys={props.missingProjectPathKeys} runningProjectPathKeys={projectActivityInputs.runningWorkdirPathKeys} @@ -249,6 +259,11 @@ export function ChatSidebarContainer(props: ChatSidebarContainerProps) { onProjectsCollapsedChange={props.onProjectsCollapsedChange} onRecentCollapsedChange={props.onRecentCollapsedChange} onCreateProject={props.onCreateProject} + onCreateWorkspaceGroup={props.onCreateWorkspaceGroup} + onRenameWorkspaceGroup={props.onRenameWorkspaceGroup} + onDeleteWorkspaceGroup={props.onDeleteWorkspaceGroup} + onMoveProjectToGroup={props.onMoveProjectToGroup} + onToggleWorkspaceGroupCollapsed={props.onToggleWorkspaceGroupCollapsed} onSelectProject={props.onSelectProject} onNewConversationForProject={props.onNewConversationForProject} onBrowseProjectInFileTree={props.onBrowseProjectInFileTree} diff --git a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjectRemoval.tsx b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjectRemoval.tsx index b1f0d7794..0a5e38ee7 100644 --- a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjectRemoval.tsx +++ b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjectRemoval.tsx @@ -1,14 +1,12 @@ +import type { WorkspaceProjectRemoveOptions } from "@liveagent/ui/components/chat/ChatHistorySidebar"; import type { ConfirmDialogOptions } from "@liveagent/ui/components/ui/confirm-dialog"; -import { memoryDeleteProject } from "@liveagent/ui/lib/memory/api"; import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; import { terminalSessionBelongsToProject } from "@liveagent/ui/lib/terminal/sessionStore"; import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; +import { removeWorkspaceProjectFromGroups } from "@liveagent/ui/lib/workspaceProjects"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; import { Terminal } from "../../../components/icons"; -import { - type ConversationPersistenceCursor, - deleteChatHistory, -} from "../../../lib/chat/history/chatHistory"; +import { tauriGitClient } from "../../../lib/git/tauriGitClient"; import { type AppSettings, DEFAULT_WORKSPACE_PROJECT_ID, @@ -20,11 +18,7 @@ import { } from "../../../lib/settings"; import { tauriTerminalClient } from "../../../lib/terminal/tauriTerminalClient"; import { asErrorMessage } from "../chatPageUtils"; -import type { ConversationRuntimeEntry } from "../runtime/chatPageRuntime"; -import { - getDefaultWorkspaceProjectPath, - listChatHistoryIdsForProjectPath, -} from "./workspaceProjectsModel"; +import { getDefaultWorkspaceProjectPath } from "./workspaceProjectsModel"; type UseWorkspaceProjectRemovalParams = { settings: AppSettings; @@ -43,14 +37,6 @@ type UseWorkspaceProjectRemovalParams = { setActiveWorkspaceProjectId: Dispatch>; setProjectRenamingId: Dispatch>; setProjectRenameDraft: Dispatch>; - isConversationRunning: (conversationId: string) => boolean; - currentConversationIdRef: MutableRefObject; - conversationRuntimeCacheRef: MutableRefObject>; - conversationPersistenceCursorRef: MutableRefObject>; - locallySyncedHistoryUpdatedAtRef: MutableRefObject>; - deleteConversationLocalCaches: (conversationId: string) => void; - disposeSubagentsForConversation: (conversationId: string) => void; - removeSharedHistoryItems: (ids: Iterable) => void; terminalProjectPathKey: string; setTerminalSessions: Dispatch>; setRightDockOpen: Dispatch>; @@ -59,10 +45,9 @@ type UseWorkspaceProjectRemovalParams = { }; /** - * Destructive workspace-project actions: full removal (conversations, - * terminals, memory, settings) plus archive/unarchive. Split from - * useWorkspaceProjects because removal needs the conversation/terminal cache - * plumbing that only exists later in ChatPage's wiring order. + * Workspace-project lifecycle actions. Removing an entry only updates settings; + * deleting a registered Worktree additionally invokes Git and closes terminals + * that would otherwise keep using the deleted directory. */ export function useWorkspaceProjectRemoval(params: UseWorkspaceProjectRemovalParams) { const { @@ -79,14 +64,6 @@ export function useWorkspaceProjectRemoval(params: UseWorkspaceProjectRemovalPar setActiveWorkspaceProjectId, setProjectRenamingId, setProjectRenameDraft, - isConversationRunning, - currentConversationIdRef, - conversationRuntimeCacheRef, - conversationPersistenceCursorRef, - locallySyncedHistoryUpdatedAtRef, - deleteConversationLocalCaches, - disposeSubagentsForConversation, - removeSharedHistoryItems, terminalProjectPathKey, setTerminalSessions, setRightDockOpen, @@ -136,6 +113,10 @@ export function useWorkspaceProjectRemoval(params: UseWorkspaceProjectRemovalPar workspaceProjects: prev.system.workspaceProjects.filter( (item) => item.id !== project.id && workspaceProjectPathKey(item.path) !== pathKey, ), + workspaceProjectGroups: removeWorkspaceProjectFromGroups( + prev.system.workspaceProjectGroups, + path, + ), hiddenWorkspaceProjectPaths: nextHidden, missingWorkspaceProjectPaths: prev.system.missingWorkspaceProjectPaths.filter( (item) => workspaceProjectPathKey(item) !== pathKey, @@ -166,38 +147,45 @@ export function useWorkspaceProjectRemoval(params: UseWorkspaceProjectRemovalPar ); const handleRemoveWorkspaceProject = useCallback( - (project: WorkspaceProject) => { + (project: WorkspaceProject, options: WorkspaceProjectRemoveOptions = {}) => { if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; + const path = project.path.trim(); + const pathKey = workspaceProjectPathKey(path); + if (pathKey && sidebarStore.getSnapshot().runningWorkdirPathKeys.has(pathKey)) { + setErrorMessage(t("chat.workspaceRemoveRunning")); + return; + } + + if (options.deleteWorktree !== true) { + setErrorMessage(null); + removeWorkspaceProjectFromSettings(project); + return; + } + void (async () => { - const path = project.path.trim(); - const pathKey = workspaceProjectPathKey(path); - const runningMessage = "项目中仍有后台任务运行,暂时不能删除该项目。"; - if (pathKey && sidebarStore.getSnapshot().runningWorkdirPathKeys.has(pathKey)) { - setErrorMessage(runningMessage); + const repositoryPath = project.worktree?.repositoryPath.trim() || ""; + if (!path || !pathKey || !repositoryPath) { + setErrorMessage(t("chat.workspaceDeleteWorktreeMetadataMissing")); + return; + } + // GitClient 上 removeWorktree 是可选能力;Tauri 端恒有实现, + // 这里仅为类型收窄并兜底提示。 + const removeWorktree = tauriGitClient.removeWorktree; + if (!removeWorktree) { + setErrorMessage(t("chat.workspaceDeleteWorktreeUnavailable")); return; } setErrorMessage(null); try { - const conversationIds = await listChatHistoryIdsForProjectPath(path); - const sidebarRunningIds = sidebarStore.getSnapshot().runningConversationIds; - const runningConversationIdsInProject = conversationIds.filter((id) => { - const key = id.trim(); - return key ? isConversationRunning(key) || sidebarRunningIds.has(key) : false; - }); - if (runningConversationIdsInProject.length > 0) { - setErrorMessage(runningMessage); - return; - } - - const terminalSessions = pathKey ? await tauriTerminalClient.list(pathKey) : []; + const terminalSessions = await tauriTerminalClient.list(pathKey); const runningTerminalCount = terminalSessions.filter((session) => session.running).length; if (runningTerminalCount > 0) { const confirmed = await requestConfirmDialog({ - title: t("chat.workspaceRemoveConfirm").replace("{name}", project.name), - subtitle: t("chat.workspaceRemoveDescription"), + title: t("chat.workspaceDeleteWorktreeConfirm").replace("{name}", project.name), + subtitle: t("chat.workspaceDeleteWorktreeDescription"), description: (
@@ -213,80 +201,68 @@ export function useWorkspaceProjectRemoval(params: UseWorkspaceProjectRemovalPar

- {t("chat.workspaceRemoveTerminalDescription")} + {t("chat.workspaceDeleteWorktreeTerminalDescription")}

), - confirmLabel: t("chat.workspaceRemoveConfirmContinue"), + confirmLabel: t("chat.workspaceDeleteWorktree"), cancelLabel: t("chat.cancel"), - closeLabel: t("chat.workspaceRemoveConfirmClose"), + closeLabel: t("chat.workspaceDeleteWorktreeConfirmClose"), tone: "warning", }); if (!confirmed) return; + await tauriTerminalClient.closeProject(pathKey); + setTerminalSessions((current) => + current.filter((session) => !terminalSessionBelongsToProject(session, pathKey)), + ); } - for (const conversationId of conversationIds) { - await deleteChatHistory(conversationId); + const response = await removeWorktree(repositoryPath, path, { + deleteBranch: options.deleteBranch === true, + }); + if (!response.worktreeRemoved) { + setErrorMessage(response.message || response.stderr || t("chat.workspaceDeleteFailed")); + return; } - const deletedConversationIds = new Set(conversationIds); - if (deletedConversationIds.size > 0) { - for (const conversationId of deletedConversationIds) { - sidebarStore.removeLocal(conversationId); - } - removeSharedHistoryItems(deletedConversationIds); - for (const conversationId of deletedConversationIds) { - conversationPersistenceCursorRef.current.delete(conversationId); - conversationRuntimeCacheRef.current.delete(conversationId); - locallySyncedHistoryUpdatedAtRef.current.delete(conversationId); - deleteConversationLocalCaches(conversationId); - disposeSubagentsForConversation(conversationId); - } - } - if (terminalSessions.length > 0) { + if (terminalSessions.length > 0 && runningTerminalCount === 0) { await tauriTerminalClient.closeProject(pathKey); setTerminalSessions((current) => current.filter((session) => !terminalSessionBelongsToProject(session, pathKey)), ); } - if (pathKey && terminalProjectPathKey === pathKey) { + if (terminalProjectPathKey === pathKey) { setRightDockOpen(false); setTerminalSessions((current) => current.filter((session) => !terminalSessionBelongsToProject(session, pathKey)), ); } - const visibleConversationId = currentConversationIdRef.current; const shouldResetVisibleConversation = - Boolean(visibleConversationId && deletedConversationIds.has(visibleConversationId)) || - Boolean(pathKey && workspaceProjectPathKey(displayedConversationWorkdir) === pathKey); - - if (path) { - await memoryDeleteProject({ - workdir: path, - actor: "tool", - reason: "workspace project removed", - }); - } + workspaceProjectPathKey(displayedConversationWorkdir) === pathKey; removeWorkspaceProjectFromSettings(project); if (shouldResetVisibleConversation) { startNewConversationActionRef.current({ workdir: getDefaultWorkspaceProjectPath(settings.system) || undefined, }); } + if (!response.ok) { + setErrorMessage(response.message || response.stderr || t("chat.workspaceDeleteFailed")); + } } catch (error) { - setErrorMessage(asErrorMessage(error, "删除项目失败")); + setErrorMessage(asErrorMessage(error, t("chat.workspaceDeleteFailed"))); } })(); }, [ - deleteConversationLocalCaches, displayedConversationWorkdir, - isConversationRunning, removeWorkspaceProjectFromSettings, + requestConfirmDialog, settings.system, sidebarStore, + startNewConversationActionRef, + t, terminalProjectPathKey, ], ); @@ -360,6 +336,7 @@ export function useWorkspaceProjectRemoval(params: UseWorkspaceProjectRemovalPar ); return { + removeWorkspaceProjectFromSettings, handleRemoveWorkspaceProject, handleArchiveWorkspaceProject, handleUnarchiveWorkspaceProject, diff --git a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts index 9d739e7c1..a3eae3a76 100644 --- a/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts +++ b/crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts @@ -1,9 +1,13 @@ +import { createUuid } from "@liveagent/ui/lib/shared/id"; import { sidebarScopeKey } from "@liveagent/ui/lib/sidebar/scope"; import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; import type { SidebarScope } from "@liveagent/ui/lib/sidebar/types"; import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector"; import { invokeFs } from "@liveagent/ui/lib/tools/fsBackend"; import { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + fallbackWorkspaceProjectName, findWorkspaceProject, mergeWorkspaceProjectsWithHistory, } from "@liveagent/ui/lib/workspaceProjects"; @@ -25,6 +29,7 @@ import { resolveWorkspaceProjects, updateCustomSettings, type WorkspaceProject, + type WorkspaceProjectGroup, workspaceProjectPathKey, } from "../../../lib/settings"; import { asErrorMessage } from "../chatPageUtils"; @@ -185,11 +190,16 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { const pathKey = project.path.trim(); if (!pathKey) return; const normalizedPathKey = workspaceProjectPathKey(pathKey); - const targetProject = - workspaceProjects.find( - (item) => - workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, - ) ?? project; + const matchedProject = workspaceProjects.find( + (item) => + workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, + ); + const targetProject = matchedProject + ? { + ...matchedProject, + ...(project.worktree ? { worktree: project.worktree } : {}), + } + : project; // 目标工作区已完全激活时提前返回,避免流式进行中触发无谓的 settings 写入与重渲染 if ( !options?.startConversation && @@ -214,7 +224,7 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { (item) => workspaceProjectPathKey(item.path) === normalizedPathKey || item.id === project.id, ); - const nextProject = existing ?? targetProject; + const nextProject = existing ? { ...targetProject, id: existing.id } : targetProject; const workspaceProjects = existing ? prev.system.workspaceProjects.map((item) => item.id === existing.id @@ -228,6 +238,7 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { : nextProject.kind === "history" ? item.kind : nextProject.kind, + worktree: nextProject.worktree ?? item.worktree, updatedAt: item.updatedAt, lastConversationAt: Math.max(item.lastConversationAt ?? 0, nextProject.lastConversationAt ?? 0) || @@ -377,6 +388,58 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { [activateWorkspaceProject], ); + // 后端返回主工作树作为稳定仓库身份;即使从 linked worktree 再创建, + // 新项目也会归到同一个源仓库分组并持久化真实关联分支。 + const handleOpenWorktree = useCallback( + (worktree: { path: string; repositoryPath: string; branch: string }) => { + const path = worktree.path.trim(); + const repositoryPath = worktree.repositoryPath.trim(); + const worktreeKey = workspaceProjectPathKey(path); + if (!path || !repositoryPath || !worktreeKey || !activeWorkspaceProject) return; + const branch = worktree.branch.trim(); + const nextProject: WorkspaceProject = { + ...createWorkspaceProjectFromPath(path, "managed"), + worktree: { + repositoryPath, + ...(branch ? { branch } : {}), + }, + }; + activateWorkspaceProject(nextProject); + setSettings((prev) => { + const sourceProject = prev.system.workspaceProjects.find( + (item) => workspaceProjectPathKey(item.path) === workspaceProjectPathKey(repositoryPath), + ); + const ensured = ensureWorktreeProjectGroup(prev.system.workspaceProjectGroups, { + name: sourceProject?.name || fallbackWorkspaceProjectName(repositoryPath), + sourceProjectPath: repositoryPath, + }); + let workspaceProjectGroups = assignWorkspaceProjectToGroup( + ensured.groups, + ensured.groupId, + repositoryPath, + ); + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + activeWorkspaceProject.path, + ); + workspaceProjectGroups = assignWorkspaceProjectToGroup( + workspaceProjectGroups, + ensured.groupId, + path, + ); + return { + ...prev, + system: { + ...prev.system, + workspaceProjectGroups, + }, + }; + }); + }, + [activateWorkspaceProject, activeWorkspaceProject, setSettings], + ); + const handleLoadWorkspaceRemoteBranches = useCallback( (remoteUrl: string) => invoke<{ defaultBranch: string; branches: string[] }>("git_list_remote_branches", { @@ -384,6 +447,95 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { }), [], ); + + const updateWorkspaceProjectGroups = useCallback( + (updater: (groups: WorkspaceProjectGroup[]) => WorkspaceProjectGroup[]) => { + setSettings((prev) => { + const next = updater(prev.system.workspaceProjectGroups); + if (next === prev.system.workspaceProjectGroups) return prev; + return { ...prev, system: { ...prev.system, workspaceProjectGroups: next } }; + }); + }, + [setSettings], + ); + + const handleCreateWorkspaceGroup = useCallback( + (nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + const now = Date.now(); + updateWorkspaceProjectGroups((groups) => [ + ...groups, + { + id: createUuid(), + name, + projectPaths: [], + createdAt: now, + updatedAt: now, + }, + ]); + }, + [updateWorkspaceProjectGroups], + ); + + const handleRenameWorkspaceGroup = useCallback( + (groupId: string, nameInput: string) => { + const name = nameInput.trim(); + if (!name) return; + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId ? { ...group, name, updatedAt: Date.now() } : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); + + const handleDeleteWorkspaceGroup = useCallback( + (groupId: string) => { + // 删除分组只解除成员归属,项目保留在列表中。 + updateWorkspaceProjectGroups((groups) => groups.filter((group) => group.id !== groupId)); + }, + [updateWorkspaceProjectGroups], + ); + + const handleMoveWorkspaceProjectToGroup = useCallback( + (projectPath: string, groupId: string | null) => { + const pathKey = workspaceProjectPathKey(projectPath); + if (!pathKey) return; + updateWorkspaceProjectGroups((groups) => { + if (groupId === null) { + // 移出所有分组 + return groups.map((group) => + group.projectPaths.some((path) => workspaceProjectPathKey(path) === pathKey) + ? { + ...group, + updatedAt: Date.now(), + projectPaths: group.projectPaths.filter( + (path) => workspaceProjectPathKey(path) !== pathKey, + ), + } + : group, + ); + } + return assignWorkspaceProjectToGroup(groups, groupId, projectPath); + }); + }, + [updateWorkspaceProjectGroups], + ); + + const handleToggleWorkspaceGroupCollapsed = useCallback( + (groupId: string) => { + updateWorkspaceProjectGroups((groups) => + groups.map((group) => + group.id === groupId + ? { ...group, collapsed: !group.collapsed, updatedAt: Date.now() } + : group, + ), + ); + }, + [updateWorkspaceProjectGroups], + ); const commitWorkspaceProjectRename = useCallback( (project: WorkspaceProject, nextNameInput: string) => { if (project.id === DEFAULT_WORKSPACE_PROJECT_ID) return; @@ -550,6 +702,13 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) { handleOpenWorkspaceFolder, handleCloneWorkspaceProject, handleOpenClonedWorkspace, + handleOpenWorktree, + workspaceProjectGroups: settings.system.workspaceProjectGroups, + handleCreateWorkspaceGroup, + handleRenameWorkspaceGroup, + handleDeleteWorkspaceGroup, + handleMoveWorkspaceProjectToGroup, + handleToggleWorkspaceGroupCollapsed, handleLoadWorkspaceRemoteBranches, handleStartRenamingWorkspaceProject, handleCommitWorkspaceProjectRename, diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 7020d630a..e242b7d5a 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -421,7 +421,8 @@ test("chat runtime controls default and follow provider model reasoning support" }), ["minimal", "low", "medium", "high"], ); - // gemini-3-pro-image:目录只有两档 low/high。 + // gemini-3-pro-image:目录只有两档 low/high(gemini-3-pro-preview 已随 + // #425 上游目录刷新移除,改用同为两档的模型覆盖该路径)。 assert.deepEqual( settings.getChatRuntimeReasoningLevelsForProvider({ providerId: "gemini", diff --git a/crates/agent-gui/test/settings/workspace-project-parent.test.mjs b/crates/agent-gui/test/settings/workspace-project-parent.test.mjs new file mode 100644 index 000000000..610b6b6f7 --- /dev/null +++ b/crates/agent-gui/test/settings/workspace-project-parent.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const settings = loader.loadModule("src/lib/settings/index.ts"); + +test("normalizeSystemSettings keeps workspace project groups", () => { + const normalized = settings.normalizeSystemSettings({ + workspaceProjectGroups: [ + { + id: "g1", + name: "Repo", + projectPaths: ["/work/repo", "/work/repo/worktrees/a"], + sourceProjectPath: "/work/repo", + collapsed: true, + createdAt: 100, + updatedAt: 100, + }, + ], + }); + assert.equal(normalized.workspaceProjectGroups.length, 1); + assert.equal(normalized.workspaceProjectGroups[0].name, "Repo"); + assert.deepEqual(normalized.workspaceProjectGroups[0].projectPaths, [ + "/work/repo", + "/work/repo/worktrees/a", + ]); + assert.equal(normalized.workspaceProjectGroups[0].sourceProjectPath, "/work/repo"); + assert.equal(normalized.workspaceProjectGroups[0].collapsed, true); +}); + +test("normalizeSystemSettings drops invalid group entries and dedupes paths", () => { + const normalized = settings.normalizeSystemSettings({ + workspaceProjectGroups: [ + { + id: "g1", + name: "Group", + projectPaths: ["/work/a", "/work/a/", " ", 42], + createdAt: 100, + updatedAt: 100, + }, + { id: "g1", name: "Duplicate id", projectPaths: [], createdAt: 100, updatedAt: 100 }, + { name: "Missing id", projectPaths: [], createdAt: 100, updatedAt: 100 }, + ], + }); + assert.equal(normalized.workspaceProjectGroups.length, 2); + assert.deepEqual(normalized.workspaceProjectGroups[0].projectPaths, ["/work/a"]); +}); + +test("legacy settings without groups normalize to an empty list", () => { + const normalized = settings.normalizeSystemSettings({}); + assert.deepEqual(normalized.workspaceProjectGroups, []); +}); diff --git a/crates/agent-gui/test/tools/git-types.test.mjs b/crates/agent-gui/test/tools/git-types.test.mjs new file mode 100644 index 000000000..0459eeeaa --- /dev/null +++ b/crates/agent-gui/test/tools/git-types.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const { isGitWorktreeBranchNotFullyMergedError } = createTsModuleLoader().loadModule( + "@liveagent/ui/lib/git/types.ts", +); + +test("recognizes an unmerged branch failure after worktree removal", () => { + assert.equal( + isGitWorktreeBranchNotFullyMergedError( + "Worktree 已移除,但分支删除失败:error: The branch 'feature' is not fully merged.", + ), + true, + ); +}); + +test("does not classify dirty worktree removal as an unmerged branch failure", () => { + assert.equal( + isGitWorktreeBranchNotFullyMergedError( + "fatal: contains modified or untracked files, use --force to delete it", + ), + false, + ); +}); diff --git a/crates/agent-gui/test/tools/workspace-project-groups.test.mjs b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs new file mode 100644 index 000000000..ee9e0fdee --- /dev/null +++ b/crates/agent-gui/test/tools/workspace-project-groups.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { + assignWorkspaceProjectToGroup, + ensureWorktreeProjectGroup, + buildWorkspaceProjectSections, + firstUnpinnedWorkspaceProjectIndex, + removeWorkspaceProjectFromGroups, + sliceWorkspaceProjectSections, +} = loader.loadModule("@liveagent/ui/lib/workspaceProjects.ts"); +function project(id, path, extra = {}) { + return { + id, + name: id, + path, + kind: "managed", + createdAt: 1, + updatedAt: 1, + ...extra, + }; +} + +function group(id, name, projectPaths = [], extra = {}) { + return { + id, + name, + projectPaths, + createdAt: 1, + updatedAt: 1, + ...extra, + }; +} + +test("assignWorkspaceProjectToGroup moves a project between groups", () => { + const groups = [ + group("g1", "Alpha", ["/work/a"]), + group("g2", "Beta", ["/work/b"]), + ]; + const next = assignWorkspaceProjectToGroup(groups, "g2", "/work/a"); + assert.deepEqual( + next.map((g) => [g.id, g.projectPaths]), + [ + ["g1", []], + ["g2", ["/work/b", "/work/a"]], + ], + ); +}); + +test("assignWorkspaceProjectToGroup is idempotent for the target group", () => { + const groups = [group("g1", "Alpha", ["/work/a"])]; + const next = assignWorkspaceProjectToGroup(groups, "g1", "/work/a"); + assert.deepEqual(next.map((g) => [g.id, g.projectPaths]), [["g1", ["/work/a"]]]); +}); + +test("removeWorkspaceProjectFromGroups removes normalized paths from every group", () => { + const groups = [ + group("g1", "Alpha", ["/work/a/", "/work/b"]), + group("g2", "Beta", ["/work/a"]), + ]; + const next = removeWorkspaceProjectFromGroups(groups, "/work/a"); + assert.deepEqual( + next.map((item) => [item.id, item.projectPaths]), + [ + ["g1", ["/work/b"]], + ["g2", []], + ], + ); +}); + +test("removeWorkspaceProjectFromGroups preserves identity when no group contains the path", () => { + const groups = [group("g1", "Alpha", ["/work/a"])]; + assert.equal(removeWorkspaceProjectFromGroups(groups, "/work/b"), groups); +}); + +test("ensureWorktreeProjectGroup reuses the group by sourceProjectPath after rename", () => { + const renamed = group("g1", "用户改名后的组", ["/work/repo"], { + sourceProjectPath: "/work/repo", + }); + const ensured = ensureWorktreeProjectGroup([renamed], { + name: "repo", + sourceProjectPath: "/work/repo", + }); + assert.equal(ensured.groupId, "g1"); + assert.equal(ensured.groups.length, 1); +}); + +test("ensureWorktreeProjectGroup creates a new group for a new source", () => { + const ensured = ensureWorktreeProjectGroup([], { + name: "repo", + sourceProjectPath: "/work/repo", + }); + assert.ok(ensured.groupId); + assert.equal(ensured.groups.length, 1); + assert.equal(ensured.groups[0].name, "repo"); + assert.equal(ensured.groups[0].sourceProjectPath, "/work/repo"); +}); + +test("buildWorkspaceProjectSections groups members under their section", () => { + const repo = project("repo", "/work/repo"); + const worktree = project("wt", "/work/wt"); + const other = project("other", "/work/other"); + const sections = buildWorkspaceProjectSections( + [repo, worktree, other], + [group("g1", "repo", ["/work/repo", "/work/wt"])], + ); + assert.deepEqual( + sections.grouped.map((s) => [s.group.id, s.projects.map((p) => p.id)]), + [["g1", ["repo", "wt"]]], + ); + assert.deepEqual(sections.ungrouped.map((p) => p.id), ["other"]); +}); + +test("buildWorkspaceProjectSections orders sections by earliest member index", () => { + const repo = project("repo", "/work/repo"); + const activeWt = project("wt", "/work/wt"); + const middle = project("middle", "/work/middle"); + // 子项目更活跃 → 输入列表中下标更小 → 整组提前 + const sections = buildWorkspaceProjectSections( + [activeWt, repo, middle], + [ + group("g1", "repo", ["/work/repo", "/work/wt"]), + group("g2", "middle", ["/work/middle"]), + ], + ); + assert.deepEqual( + sections.grouped.map((s) => s.group.id), + ["g1", "g2"], + ); +}); + +test("buildWorkspaceProjectSections ignores members missing from the list", () => { + const repo = project("repo", "/work/repo"); + const sections = buildWorkspaceProjectSections( + [repo], + [group("g1", "repo", ["/work/repo", "/gone/worktree"])], + ); + assert.deepEqual(sections.grouped[0].projects.map((p) => p.id), ["repo"]); +}); + +test("firstUnpinnedWorkspaceProjectIndex marks the divider inside ungrouped projects", () => { + const pinned = project("pinned", "/work/pinned", { isPinned: true, pinnedAt: 2 }); + const regular = project("regular", "/work/regular"); + assert.equal(firstUnpinnedWorkspaceProjectIndex([pinned, regular]), 1); + assert.equal(firstUnpinnedWorkspaceProjectIndex([regular, pinned]), -1); + assert.equal(firstUnpinnedWorkspaceProjectIndex([pinned]), -1); +}); + +test("sliceWorkspaceProjectSections never splits a group", () => { + const repo = project("repo", "/work/repo"); + const wt = project("wt", "/work/wt"); + const a = project("a", "/work/a"); + const b = project("b", "/work/b"); + const sections = buildWorkspaceProjectSections( + [repo, wt, a, b], + [ + group("g1", "repo", ["/work/repo", "/work/wt"]), + group("g2", "a", ["/work/a"]), + group("g3", "b", ["/work/b"]), + ], + ); + // g1 有 2 个成员,超出上限 1 时整组都放不下 → 整组隐藏,绝不拆开。 + const sliced = sliceWorkspaceProjectSections(sections, 1); + assert.equal(sliced.sections.grouped.length, 0); + assert.equal(sliced.sections.ungrouped.length, 0); + assert.equal(sliced.hiddenProjectCount, 4); +}); + +test("sliceWorkspaceProjectSections caps ungrouped projects", () => { + const projects = [0, 1, 2, 3, 4].map((index) => + project(`p${index}`, `/work/p${index}`), + ); + const sections = buildWorkspaceProjectSections(projects, []); + const sliced = sliceWorkspaceProjectSections(sections, 2); + assert.deepEqual( + sliced.sections.ungrouped.map((p) => p.id), + ["p0", "p1"], + ); + assert.equal(sliced.hiddenProjectCount, 3); +}); + +test("sliceWorkspaceProjectSections fills remaining capacity with ungrouped", () => { + const repo = project("repo", "/work/repo"); + const wt = project("wt", "/work/wt"); + const a = project("a", "/work/a"); + const b = project("b", "/work/b"); + const sections = buildWorkspaceProjectSections( + [repo, wt, a, b], + [group("g1", "repo", ["/work/repo", "/work/wt"])], + ); + // g1 占 2 个名额,上限 4 的剩余 2 个分给未分组项目。 + const sliced = sliceWorkspaceProjectSections(sections, 4); + assert.equal(sliced.sections.grouped.length, 1); + assert.equal(sliced.sections.grouped[0].projects.length, 2); + assert.deepEqual( + sliced.sections.ungrouped.map((p) => p.id), + ["a", "b"], + ); + assert.equal(sliced.hiddenProjectCount, 0); +}); + +test("single project belongs to exactly one group at a time", () => { + const groups = [ + group("g1", "Alpha", ["/work/a"]), + group("g2", "Beta", []), + ]; + const moved = assignWorkspaceProjectToGroup(groups, "g2", "/work/a"); + const counts = moved.map( + (g) => g.projectPaths.filter((p) => p === "/work/a").length, + ); + assert.deepEqual(counts, [0, 1]); +}); diff --git a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx index 3793155ed..74f77cbc0 100644 --- a/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-ui/src/components/chat/ChatHistorySidebar.tsx @@ -68,9 +68,24 @@ import { useState, } from "react"; import type { SidebarConversation } from "../../lib/sidebar/types"; +import { + buildWorkspaceProjectSections, + firstUnpinnedWorkspaceProjectIndex, + sliceWorkspaceProjectSections, +} from "../../lib/workspaceProjects"; +import type { WorkspaceProjectGroup } from "../../lib/workspaceProjectTypes"; export type ChatHistorySidebarListStatus = "initial" | "loading" | "syncing" | "ready"; export type ChatHistorySidebarMutationKind = "rename" | "pin" | "move" | "delete"; +export type WorkspaceProjectRemoveOptions = { + deleteWorktree?: boolean; + deleteBranch?: boolean; +}; + +type PendingWorkspaceProjectAction = { + projectId: string; + mode: "remove" | "deleteWorktree"; +}; type ChatHistorySidebarProps = { items: readonly SidebarConversation[]; @@ -103,6 +118,9 @@ type ChatHistorySidebarProps = { showProjects?: boolean; // Pre-sorted by the container (pinned/running/activity); rendered as-is. projects?: WorkspaceProject[]; + // Sidebar project groups; worktree projects are auto-grouped under their + // source repository project. + workspaceProjectGroups?: WorkspaceProjectGroup[]; activeProjectId?: string; missingProjectPathKeys: ReadonlySet; runningProjectPathKeys: ReadonlySet; @@ -113,6 +131,11 @@ type ChatHistorySidebarProps = { onProjectsCollapsedChange?: (collapsed: boolean) => void; onRecentCollapsedChange?: (collapsed: boolean) => void; onCreateProject?: () => void; + onCreateWorkspaceGroup?: (name: string) => void; + onRenameWorkspaceGroup?: (groupId: string, name: string) => void; + onDeleteWorkspaceGroup?: (groupId: string) => void; + onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void; + onToggleWorkspaceGroupCollapsed?: (groupId: string) => void; onSelectProject?: (project: WorkspaceProject) => void; onNewConversationForProject?: (project: WorkspaceProject) => void; onBrowseProjectInFileTree?: (project: WorkspaceProject) => void; @@ -123,7 +146,7 @@ type ChatHistorySidebarProps = { onCommitProjectRename?: () => void; onCancelProjectRename?: () => void; onSetProjectPinned?: (project: WorkspaceProject, isPinned: boolean) => void; - onRemoveProject?: (project: WorkspaceProject) => void; + onRemoveProject?: (project: WorkspaceProject, options?: WorkspaceProjectRemoveOptions) => void; onArchiveProject?: (project: WorkspaceProject) => void; onUnarchiveProject?: (project: WorkspaceProject) => void; // Path keys of archived workspaces; those rows render disabled in a @@ -181,9 +204,7 @@ const SIDEBAR_RECENT_MIN_BODY_HEIGHT = 160; // share so the recent section sits a little higher and gets a little more room. const SIDEBAR_PROJECTS_BODY_DEFAULT_RATIO = 0.5; const SIDEBAR_MOBILE_PROJECTS_BODY_DEFAULT_RATIO = 0.4; -// Projects are not virtualized; cap the rendered rows and offer an explicit -// "show all (N)" expansion instead. -const SIDEBAR_PROJECT_RENDER_CAP = 30; +const PROJECT_LIST_COLLAPSED_MAX = 30; const EMPTY_PROJECT_PATH_KEYS = new Set(); const HISTORY_LOADING_SKELETON_ROWS = [ { title: "w-36", meta: "w-20" }, @@ -964,13 +985,116 @@ const HistoryRow = memo(function HistoryRow(props: HistoryRowProps) { ); }, areHistoryRowPropsEqual); +// 项目分组标题行:折叠切换、成员计数、重命名与删除。 +function ProjectGroupHeader(props: { + group: WorkspaceProjectGroup; + memberCount: number; + isRenaming: boolean; + renameDraft: string; + onRenameDraftChange: (value: string) => void; + onCommitRename: () => void; + onCancelRename: () => void; + onToggleCollapsed: () => void; + onStartRename: () => void; + onDelete: () => void; +}) { + const { + group, + memberCount, + isRenaming, + renameDraft, + onRenameDraftChange, + onCommitRename, + onCancelRename, + onToggleCollapsed, + onStartRename, + onDelete, + } = props; + const { t } = useLocale(); + + if (isRenaming) { + return ( +
+ onRenameDraftChange(event.currentTarget.value)} + onBlur={onCommitRename} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onCommitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + onCancelRename(); + } + }} + className="h-7 min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-[calc(13px*var(--zone-font-scale,1))] font-semibold shadow-none outline-none focus-visible:border-0 focus-visible:bg-transparent" + autoFocus + /> +
+ ); + } + + return ( +
+ + + + } + aria-label={t("chat.workspaceGroupActions")} + title={t("chat.workspaceGroupActions")} + > + + + + + + {t("chat.workspaceGroupRename")} + + + + {t("chat.workspaceGroupDelete")} + + + +
+ ); +} + const ProjectRow = memo(function ProjectRow(props: { project: WorkspaceProject; isActive: boolean; isMissing: boolean; isRunning: boolean; isRenaming: boolean; - isPendingRemove: boolean; + pendingAction: PendingWorkspaceProjectAction["mode"] | null; isInteractionDisabled: boolean; renameDraft: string; onSelectProject: (project: WorkspaceProject) => void; @@ -982,7 +1106,7 @@ const ProjectRow = memo(function ProjectRow(props: { onCommitProjectRename: () => void; onCancelProjectRename: () => void; onSetProjectPinned: (project: WorkspaceProject, isPinned: boolean) => void; - onRemoveProject: (project: WorkspaceProject) => void; + onRemoveProject: (project: WorkspaceProject, options?: WorkspaceProjectRemoveOptions) => void; // Archived rows render disabled: no selection (so no new conversations), // no pin — but rename/remove/browse stay available from the menu. isArchived: boolean; @@ -990,7 +1114,12 @@ const ProjectRow = memo(function ProjectRow(props: { canArchive: boolean; onArchiveProject: (project: WorkspaceProject) => void; onUnarchiveProject: (project: WorkspaceProject) => void; - onSetPendingRemove: (projectId: string | null) => void; + onSetPendingAction: (action: PendingWorkspaceProjectAction | null) => void; + // 分组内的项目行:相对组头缩进,形成层级视觉。 + indented?: boolean; + // 分组归属:菜单中提供“移动到分组”子菜单。 + workspaceProjectGroups?: WorkspaceProjectGroup[]; + onMoveProjectToGroup?: (projectPath: string, groupId: string | null) => void; menuOpen: boolean; onMenuOpenChange: (projectId: string, open: boolean) => void; }) { @@ -1000,7 +1129,7 @@ const ProjectRow = memo(function ProjectRow(props: { isMissing, isRunning, isRenaming, - isPendingRemove, + pendingAction, isInteractionDisabled, renameDraft, onSelectProject, @@ -1017,7 +1146,10 @@ const ProjectRow = memo(function ProjectRow(props: { canArchive, onArchiveProject, onUnarchiveProject, - onSetPendingRemove, + onSetPendingAction, + indented = false, + workspaceProjectGroups = [], + onMoveProjectToGroup, menuOpen, onMenuOpenChange, } = props; @@ -1030,6 +1162,12 @@ const ProjectRow = memo(function ProjectRow(props: { const suppressMenuReturnFocusRef = useRef(false); const isDefaultProject = project.id === DEFAULT_WORKSPACE_PROJECT_ID; const isPinned = project.isPinned === true; + const [deleteBranchWithWorktree, setDeleteBranchWithWorktree] = useState(false); + const currentGroupId = workspaceProjectGroups.find((group) => + group.projectPaths.some( + (path) => workspaceProjectPathKey(path) === workspaceProjectPathKey(project.path), + ), + )?.id; const ProjectFolderIcon = isActive ? FolderOpen : FolderClosed; useEffect(() => { @@ -1039,6 +1177,12 @@ const ProjectRow = memo(function ProjectRow(props: { inputRef.current?.select(); }, [isRenaming]); + useEffect(() => { + if (pendingAction !== "deleteWorktree") { + setDeleteBranchWithWorktree(false); + } + }, [pendingAction]); + const handleStartRenamingFromMenu = useCallback(() => { if (isInteractionDisabled) { return; @@ -1051,20 +1195,40 @@ const ProjectRow = memo(function ProjectRow(props: { if (isInteractionDisabled) { return; } - onSetPendingRemove(project.id); - }, [isInteractionDisabled, onSetPendingRemove, project.id]); + onSetPendingAction({ projectId: project.id, mode: "remove" }); + }, [isInteractionDisabled, onSetPendingAction, project.id]); - const handleConfirmRemove = useCallback(() => { - onSetPendingRemove(null); - if (isInteractionDisabled) { + const handleRequestDeleteWorktree = useCallback(() => { + if (isInteractionDisabled || !project.worktree) { return; } - onRemoveProject(project); - }, [isInteractionDisabled, onRemoveProject, onSetPendingRemove, project]); + onSetPendingAction({ projectId: project.id, mode: "deleteWorktree" }); + }, [isInteractionDisabled, onSetPendingAction, project.id, project.worktree]); - const handleCancelRemove = useCallback(() => { - onSetPendingRemove(null); - }, [onSetPendingRemove]); + const handleConfirmPendingAction = useCallback(() => { + const mode = pendingAction; + onSetPendingAction(null); + if (isInteractionDisabled || !mode) { + return; + } + onRemoveProject( + project, + mode === "deleteWorktree" + ? { deleteWorktree: true, deleteBranch: deleteBranchWithWorktree } + : undefined, + ); + }, [ + deleteBranchWithWorktree, + isInteractionDisabled, + onRemoveProject, + onSetPendingAction, + pendingAction, + project, + ]); + + const handleCancelPendingAction = useCallback(() => { + onSetPendingAction(null); + }, [onSetPendingAction]); const handleTogglePinned = useCallback(() => { if (isInteractionDisabled) { @@ -1111,21 +1275,54 @@ const ProjectRow = memo(function ProjectRow(props: { [isInteractionDisabled, onMenuOpenChange, project.id], ); - if (isPendingRemove) { + if (pendingAction) { + const deletingWorktree = pendingAction === "deleteWorktree" && Boolean(project.worktree); return (

- {t("chat.workspaceRemoveConfirm").replace("{name}", project.name)} + {t( + deletingWorktree ? "chat.workspaceDeleteWorktreeConfirm" : "chat.workspaceRemoveConfirm", + ).replace("{name}", project.name)}

- {isRunning ? t("chat.workspaceRemoveRunning") : t("chat.workspaceRemoveDescription")} + {isRunning + ? t("chat.workspaceRemoveRunning") + : t( + deletingWorktree + ? "chat.workspaceDeleteWorktreeDescription" + : "chat.workspaceRemoveDescription", + )}

+ {deletingWorktree ? ( + <> +

+ {project.path} +

+ {project.worktree?.branch ? ( + + ) : null} + + ) : null}
@@ -1150,6 +1347,7 @@ const ProjectRow = memo(function ProjectRow(props: { ref={rowRef} className={cn( "group/project grid h-[30px] grid-cols-[minmax(0,1fr)_auto] items-center rounded-lg pl-1 transition-colors", + indented && "pl-5", isMissing ? "text-destructive hover:bg-destructive/10" : isArchived @@ -1415,9 +1613,19 @@ const ProjectRow = memo(function ProjectRow(props: { onSelect={handleRequestRemove} className="gap-2 text-destructive focus:bg-destructive/10 focus:text-destructive" > - - {t("chat.workspaceRemove")} + + {t("chat.workspaceRemoveOnly")} + {project.worktree ? ( + + + {t("chat.workspaceDeleteWorktree")} + + ) : null} ) : null} {!isArchived && canArchive ? ( @@ -1430,6 +1638,45 @@ const ProjectRow = memo(function ProjectRow(props: { {t("chat.workspaceArchive")} ) : null} + {onMoveProjectToGroup ? ( + + + + {t("chat.workspaceGroupMove")} + + + {workspaceProjectGroups.map((group) => ( + onMoveProjectToGroup(project.path, group.id)} + className="gap-2 text-xs" + > + {group.id === currentGroupId ? ( + + ) : ( + + )} + {group.name} + + ))} + {currentGroupId ? ( + onMoveProjectToGroup(project.path, null)} + className="gap-2 text-xs" + > + + {t("chat.workspaceGroupUngroup")} + + ) : null} + + + ) : null} {isArchived ? ( (null); + const [pendingProjectAction, setPendingProjectAction] = + useState(null); const [showAllProjects, setShowAllProjects] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); const [openProjectMenuId, setOpenProjectMenuId] = useState(null); @@ -1706,11 +1960,13 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi setPendingDeleteId(id); } }); - const handleSetPendingProjectRemove = useStableEvent((projectId: string | null) => { - if (!sectionsDisabled || projectId === null) { - setPendingProjectRemoveId(projectId); - } - }); + const handleSetPendingProjectAction = useStableEvent( + (action: PendingWorkspaceProjectAction | null) => { + if (!sectionsDisabled || action === null) { + setPendingProjectAction(action); + } + }, + ); const handleProjectsCollapsedChange = useStableEvent(() => { if (!sectionsDisabled) { onProjectsCollapsedChange?.(!projectsCollapsed); @@ -1721,11 +1977,6 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi onRecentCollapsedChange?.(!recentCollapsed); } }); - const handleCreateProject = useStableEvent(() => { - if (!sectionsDisabled) { - onCreateProject?.(); - } - }); const handleShowAllProjects = useStableEvent(() => { if (!sectionsDisabled) { setShowAllProjects((current) => !current); @@ -1774,11 +2025,13 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi onSetProjectPinned?.(project, isPinned); } }); - const handleRemoveProject = useStableEvent((project: WorkspaceProject) => { - if (!sectionsDisabled) { - onRemoveProject?.(project); - } - }); + const handleRemoveProject = useStableEvent( + (project: WorkspaceProject, options?: WorkspaceProjectRemoveOptions) => { + if (!sectionsDisabled) { + onRemoveProject?.(project, options); + } + }, + ); const handleArchiveProject = useStableEvent((project: WorkspaceProject) => { if (!sectionsDisabled) { onArchiveProject?.(project); @@ -1937,24 +2190,88 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi ), [archivedProjectPathKeys, projects], ); - // Projects arrive pre-sorted from the container; only the render cap is - // applied here. - const renderedProjects = useMemo( - () => (showAllProjects ? activeProjects : activeProjects.slice(0, SIDEBAR_PROJECT_RENDER_CAP)), - [activeProjects, showAllProjects], + // Projects arrive pre-sorted from the container; the view organizes them + // into group sections (worktree projects auto-grouped under their source + // repository) plus the ungrouped remainder. The collapsed view slices by + // section so a group is never split. + const projectSections = useMemo( + () => buildWorkspaceProjectSections(activeProjects, workspaceProjectGroups ?? []), + [activeProjects, workspaceProjectGroups], ); + const slicedSections = useMemo( + () => + showAllProjects + ? { sections: projectSections, hiddenProjectCount: 0 } + : sliceWorkspaceProjectSections(projectSections, PROJECT_LIST_COLLAPSED_MAX), + [projectSections, showAllProjects], + ); + const renderedSections = slicedSections.sections; + const hiddenProjectCount = slicedSections.hiddenProjectCount; // Divider slot between the pinned block and the rest of the projects. - const firstUnpinnedProjectIndex = useMemo(() => { - if (renderedProjects[0]?.isPinned !== true) { + // The first section's first member determines pinned placement; a pinned or + // running member promotes its whole section via the earliest sorted index. + const firstUnpinnedSectionIndex = useMemo(() => { + const firstMember = renderedSections.grouped[0]?.projects[0] ?? renderedSections.ungrouped[0]; + if (firstMember?.isPinned !== true) { return -1; } - const index = renderedProjects.findIndex((project) => project.isPinned !== true); - return index > 0 ? index : -1; - }, [renderedProjects]); + const groupedIndex = renderedSections.grouped.findIndex( + (section) => section.projects[0]?.isPinned !== true, + ); + if (groupedIndex > 0) return groupedIndex; + if (renderedSections.ungrouped[0]?.isPinned === true) return -1; + return renderedSections.grouped.length; + }, [renderedSections]); + const firstUnpinnedUngroupedIndex = + renderedSections.grouped.length === 0 + ? firstUnpinnedWorkspaceProjectIndex(renderedSections.ungrouped) + : -1; // Archiving must always leave at least one active workspace behind. const canArchiveProjects = Boolean(onArchiveProject) && activeProjects.length > 1; const [archivedGroupOpen, setArchivedGroupOpen] = useState(false); - const hasCappedProjects = activeProjects.length > SIDEBAR_PROJECT_RENDER_CAP; + const [creatingGroup, setCreatingGroup] = useState(false); + const [groupDraft, setGroupDraft] = useState(""); + const [renamingGroupId, setRenamingGroupId] = useState(null); + const [groupRenameDraft, setGroupRenameDraft] = useState(""); + const { confirm: requestGroupDeleteConfirm, dialog: groupDeleteDialog } = useConfirmDialog(); + + const commitNewGroup = useCallback(() => { + const name = groupDraft.trim(); + if (name) onCreateWorkspaceGroup?.(name); + setCreatingGroup(false); + setGroupDraft(""); + }, [groupDraft, onCreateWorkspaceGroup]); + + const cancelNewGroup = useCallback(() => { + setCreatingGroup(false); + setGroupDraft(""); + }, []); + + const commitGroupRename = useCallback(() => { + const name = groupRenameDraft.trim(); + if (renamingGroupId && name) onRenameWorkspaceGroup?.(renamingGroupId, name); + setRenamingGroupId(null); + setGroupRenameDraft(""); + }, [groupRenameDraft, onRenameWorkspaceGroup, renamingGroupId]); + + const cancelGroupRename = useCallback(() => { + setRenamingGroupId(null); + setGroupRenameDraft(""); + }, []); + + const requestDeleteGroup = useCallback( + async (group: WorkspaceProjectGroup) => { + const confirmed = await requestGroupDeleteConfirm({ + title: t("chat.workspaceGroupDeleteConfirmTitle").replace("{name}", group.name), + description: t("chat.workspaceGroupDeleteConfirmDescription"), + confirmLabel: t("chat.workspaceGroupDelete"), + cancelLabel: t("chat.cancel"), + tone: "destructive", + }); + if (confirmed) onDeleteWorkspaceGroup?.(group.id); + }, + [onDeleteWorkspaceGroup, requestGroupDeleteConfirm, t], + ); const sidebarSectionLayout = useMemo(() => { const { containerHeight, @@ -2088,7 +2405,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi setOpenMenuId(null); setOpenProjectMenuId(null); setPendingDeleteId(null); - setPendingProjectRemoveId(null); + setPendingProjectAction(null); exitSelectionMode(); handleCancelRename(); handleCancelProjectRename(); @@ -2101,13 +2418,13 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }, [exitSelectionMode, handleCancelProjectRename, handleCancelRename, sectionsDisabled]); useEffect(() => { - if (!pendingProjectRemoveId) { + if (!pendingProjectAction) { return; } - if (!projects.some((project) => project.id === pendingProjectRemoveId)) { - setPendingProjectRemoveId(null); + if (!projects.some((project) => project.id === pendingProjectAction.projectId)) { + setPendingProjectAction(null); } - }, [pendingProjectRemoveId, projects]); + }, [pendingProjectAction, projects]); useEffect(() => { if (pendingDeleteId !== null || renamingId !== null) { @@ -2591,18 +2908,49 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi style={{ transform: `rotate(${projectsCollapsed ? 0 : 90}deg)` }} /> - + + + } + > + + + + onCreateProject?.()} + className="gap-2 text-xs" + > + + {t("chat.workspaceCreate")} + + { + setCreatingGroup(true); + setGroupDraft(""); + }} + className="gap-2 text-xs" + > + + {t("chat.workspaceGroupCreate")} + + +
- {renderedProjects.map((project, projectIndex) => { - const pathKey = workspaceProjectPathKey(project.path); + {creatingGroup ? ( +
+ + setGroupDraft(event.currentTarget.value)} + onBlur={commitNewGroup} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitNewGroup(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelNewGroup(); + } + }} + placeholder={t("chat.workspaceGroupNamePlaceholder")} + className="h-7 min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-[calc(13px*var(--zone-font-scale,1))] shadow-none outline-none focus-visible:border-0 focus-visible:bg-transparent" + autoFocus + /> + + +
+ ) : null} + {renderedSections.grouped.map((section, sectionIndex) => { + const { group, projects: members } = section; + const collapsed = group.collapsed === true; return ( - - {projectIndex === firstUnpinnedProjectIndex ? ( + + {sectionIndex === firstUnpinnedSectionIndex ? (