From 175d297a36f6d78d99f005d4ec419b97784b78e4 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 12:58:18 +0800 Subject: [PATCH 01/41] feat: add desktop workbench sidebar shell --- .../actions/use-workspace-screen-model.ts | 13 +- .../src/features/workspace/atoms/layout.ts | 11 ++ .../web/src/features/workspace/index.test.tsx | 154 ++++++++++++++++-- .../views/desktop/workspace-desktop-view.tsx | 124 +++++--------- .../workspace/views/shared/explorer-panel.tsx | 113 +++++++++++++ .../views/shared/file-tree-panel.test.tsx | 15 ++ .../views/shared/file-tree-panel.tsx | 44 ++--- .../views/shared/workspace-activity-bar.tsx | 49 ++++++ packages/web/src/locales/en.json | 11 ++ packages/web/src/locales/zh.json | 11 ++ packages/web/src/styles/components.css | 120 +++++++++++--- 11 files changed, 523 insertions(+), 142 deletions(-) create mode 100644 packages/web/src/features/workspace/views/shared/explorer-panel.tsx create mode 100644 packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx diff --git a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts index d108470f..13c2aeb2 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts +++ b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts @@ -15,6 +15,7 @@ import { collectSessionIds } from "../../agent-panes/pane-layout-tree"; import { activeFilePathAtomFamily, branchQuickPickAtom, + desktopSidebarViewAtom, focusModeAtom, gitDiffPreviewAtomFamily, gitStateAtomFamily, @@ -23,7 +24,6 @@ import { } from "../atoms"; import { useWorkspaceLayoutActions } from "./use-workspace-layout-actions"; -export type WorkspaceSidebarTab = "files" | "git"; export type WorkspaceMainAreaMode = "agent" | "editor"; export type MobileWorkspaceSheetKind = "files" | "terminal" | "supervisor" | null; export type MobileFilesRoute = @@ -52,6 +52,7 @@ export function useWorkspaceScreenModel() { const focusMode = useAtomValue(focusModeAtom); const terminalPanelVisible = useAtomValue(terminalPanelVisibleAtom); const sidebarCollapsed = useAtomValue(sidebarCollapsedAtom); + const desktopSidebarView = useAtomValue(desktopSidebarViewAtom); const { sessions, paneLayout } = useWorkspaceSessions(workspace); const dispatch = useAtomValue(dispatchCommandAtom); const setBranchQuickPick = useSetAtom(branchQuickPickAtom); @@ -60,7 +61,6 @@ export function useWorkspaceScreenModel() { const paneActions = usePaneActions(workspaceId); const sessionActions = useSessionActions(); - const [sidebarTab, setSidebarTab] = useState("files"); const [createRequest, setCreateRequest] = useState(null); const [panelRefreshToken, setPanelRefreshToken] = useState(0); const [mobileSheet, setMobileSheet] = useState(null); @@ -112,13 +112,13 @@ export function useWorkspaceScreenModel() { return; } - setSidebarTab("git"); + store.set(desktopSidebarViewAtom, "source-control"); setBranchQuickPick({ visible: true, workspaceId: workspace.id, inputValue: "", }); - }, [setBranchQuickPick, workspace]); + }, [setBranchQuickPick, store, workspace]); const handleOpenFileCreate = useCallback(() => { setCreateRequest((previous) => ({ @@ -280,9 +280,10 @@ export function useWorkspaceScreenModel() { restoreMobileSession, selectMobileSession, sessions, - setSidebarTab, + desktopSidebarView, + setDesktopSidebarView: (view: typeof desktopSidebarView) => + store.set(desktopSidebarViewAtom, view), sidebarCollapsed, - sidebarTab, closeMobileSheet, terminalPanelVisible, updateMobileFilesRoute, diff --git a/packages/web/src/features/workspace/atoms/layout.ts b/packages/web/src/features/workspace/atoms/layout.ts index 92580b14..642ba1ce 100644 --- a/packages/web/src/features/workspace/atoms/layout.ts +++ b/packages/web/src/features/workspace/atoms/layout.ts @@ -6,6 +6,8 @@ import { atomWithStorage } from "jotai/utils"; +export type DesktopSidebarView = "explorer" | "search" | "source-control"; + /** * Focus mode toggle (hides left/bottom panels) * Persisted: ui.focusMode @@ -29,6 +31,15 @@ export const bottomPanelHeightAtom = atomWithStorage("ui.bottomPanelHeight", 200 */ export const sidebarCollapsedAtom = atomWithStorage("ui.sidebarCollapsed", false); +/** + * Desktop sidebar active view + * Persisted: ui.desktopSidebarView + */ +export const desktopSidebarViewAtom = atomWithStorage( + "ui.desktopSidebarView", + "explorer" +); + /** * Terminal panel visible state * Persisted: ui.terminalPanelVisible diff --git a/packages/web/src/features/workspace/index.test.tsx b/packages/web/src/features/workspace/index.test.tsx index dcb8f082..2114b314 100644 --- a/packages/web/src/features/workspace/index.test.tsx +++ b/packages/web/src/features/workspace/index.test.tsx @@ -203,7 +203,7 @@ describe("WorkspacePage", () => { expect(document.querySelector('[data-icon-semantic="file.action.newFolder"]')).toBeTruthy(); expect(screen.queryByRole("button", { name: /refresh|刷新/i })).toBeNull(); - fireEvent.click(screen.getByRole("tab", { name: "Git" })); + fireEvent.click(screen.getByRole("button", { name: /Source Control|源代码管理/i })); expect(screen.getByTestId("git-panel")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /^new file$|^新建文件$/i })).toBeNull(); @@ -274,11 +274,11 @@ describe("WorkspacePage", () => { expect(branchButton?.querySelector('[data-icon-semantic="git.footer.branch"]')).toBeTruthy(); fireEvent.click(branchButton as HTMLElement); - const gitTab = screen.getByRole("tab", { name: "Git" }); - expect(screen.getByRole("tablist", { name: "Workspace sections" })).toBeInTheDocument(); - expect(gitTab).toHaveAttribute("aria-selected", "true"); - expect(gitTab).toHaveClass("workspace-sidebar-panel__tab", "active"); - expect(gitTab).not.toHaveClass("panel-tab"); + const sourceControlButton = screen.getByRole("button", { name: /Source Control|源代码管理/i }); + expect( + screen.getByRole("navigation", { name: /Workspace activity bar|工作区活动栏/i }) + ).toBeInTheDocument(); + expect(sourceControlButton).toHaveAttribute("aria-pressed", "true"); expect( await screen.findByPlaceholderText("Search branches or create new branch...") ).toBeInTheDocument(); @@ -289,7 +289,7 @@ describe("WorkspacePage", () => { }); }); - it("uses workspace sidebar-specific tab styling without legacy panel-tab classes", async () => { + it("uses workspace activity bar styling without legacy tab chrome", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { return { @@ -334,15 +334,141 @@ describe("WorkspacePage", () => { ); - await screen.findByTestId("file-tree-panel"); + await screen.findByRole("button", { name: /Explorer|资源管理器/i }); + + expect(document.querySelector(".workspace-activity-bar")).toBeTruthy(); + expect(document.querySelector(".workspace-sidebar-panel__tabs")).toBeNull(); + expect(document.querySelector(".workspace-sidebar-panel__tab")).toBeNull(); + }); + + it("renders an explorer-first activity bar and removes the desktop tree search box", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); - const filesTab = screen.getByRole("tab", { name: /Files|文件/i }); - const gitTab = screen.getByRole("tab", { name: "Git" }); + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(openFilesAtomFamily("ws-test"), { + "README.md": { + kind: "text", + path: "README.md", + content: "# README", + savedContent: "# README", + baseHash: "hash-readme", + isDirty: false, + }, + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "export const app = true;", + savedContent: "export const app = true;", + baseHash: "hash-app", + isDirty: false, + }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); - expect(filesTab).toHaveClass("workspace-sidebar-panel__tab", "active"); - expect(filesTab).not.toHaveClass("panel-tab"); - expect(gitTab).toHaveClass("workspace-sidebar-panel__tab"); - expect(gitTab).not.toHaveClass("panel-tab"); + render( + + + + } /> + + + + ); + + await screen.findByText(/Open Editors|打开的编辑器/i); + + const explorerButton = screen.getByRole("button", { name: /Explorer|资源管理器/i }); + expect(explorerButton).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "README.md" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "src/app.tsx" })).toBeInTheDocument(); + expect(screen.queryByLabelText(/Search files|搜索文件/i)).toBeNull(); + expect(document.querySelector(".workspace-activity-bar")).toBeTruthy(); + }); + + it("switches desktop sidebar views from the activity bar", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /Search|搜索/i })); + await waitFor(() => { + expect( + document.querySelector(".workspace-sidebar-view .panel-header__title") + ).toHaveTextContent(/Search|搜索/i); + }); + + fireEvent.click(screen.getByRole("button", { name: /Source Control|源代码管理/i })); + expect(screen.getByTestId("git-panel")).toBeInTheDocument(); }); it("does not render a duplicate worktree entry button in the desktop git header", async () => { diff --git a/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx b/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx index 072f4916..a1604beb 100644 --- a/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx +++ b/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx @@ -1,15 +1,6 @@ import { useSetAtom } from "jotai"; -import { ChevronsUp } from "lucide-react"; -import { type FC, useEffect, useRef, useState } from "react"; -import { - EmptyState, - IconButton, - Tab, - TabList, - Tabs, - ThemedIcon, - Tooltip, -} from "../../../../components/ui"; +import { type FC, useEffect, useRef } from "react"; +import { EmptyState } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { AgentPanes } from "../../../agent-panes"; import { CodeEditorHost } from "../../../code-editor/views/shared/code-editor-host"; @@ -19,17 +10,18 @@ import { TopBar } from "../../../topbar"; import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen"; import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model"; import { sidebarCollapsedAtom } from "../../atoms"; -import { FileTreePanel } from "../shared/file-tree-panel"; +import { ExplorerPanel } from "../shared/explorer-panel"; import { GitPanel } from "../shared/git-panel"; +import { WorkspaceActivityBar } from "../shared/workspace-activity-bar"; import { WorkspaceStatusBar } from "../shared/workspace-status-bar"; export const WorkspaceDesktopView: FC = () => { const fullscreenRootRef = useRef(null); const fullscreenController = useWorkspaceFullscreen(fullscreenRootRef); - const [fileTreeCollapseVersion, setFileTreeCollapseVersion] = useState(0); const t = useTranslation(); const { createRequest, + desktopSidebarView, focusMode, gitState, handleBottomMouseDown, @@ -41,9 +33,8 @@ export const WorkspaceDesktopView: FC = () => { leftPanelWidth, leftPanelRef, mainAreaMode, - setSidebarTab, + setDesktopSidebarView, sidebarCollapsed, - sidebarTab, terminalPanelVisible, workspace, bottomPanelHeight, @@ -64,19 +55,25 @@ export const WorkspaceDesktopView: FC = () => { if (event.key === "1") { event.preventDefault(); - setSidebarTab("files"); + setDesktopSidebarView("explorer"); return; } if (event.key === "2") { event.preventDefault(); - setSidebarTab("git"); + setDesktopSidebarView("search"); + return; + } + + if (event.key === "3") { + event.preventDefault(); + setDesktopSidebarView("source-control"); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setSidebarCollapsed, setSidebarTab]); + }, [setDesktopSidebarView, setSidebarCollapsed]); if (!workspace) { return ( @@ -106,73 +103,42 @@ export const WorkspaceDesktopView: FC = () => { style={{ width: `${leftPanelWidth}px` }} >
- - - - {t("file.title")} - - - {t("label.git")} - - - - } - actions={ -
- {sidebarTab === "files" ? ( - <> - - } - onClick={handleOpenFileCreate} - size="sm" - /> - - - } - onClick={handleOpenFolderCreate} - size="sm" - /> - - - } - onClick={() => setFileTreeCollapseVersion((value) => value + 1)} - size="sm" - /> - - - ) : null} -
- } + -
- {sidebarTab === "files" ? ( - + {desktopSidebarView === "explorer" ? ( + - ) : ( - - )} + ) : null} + + {desktopSidebarView === "search" ? ( +
+ +
+ {t("workspace.search.empty")}

} + /> +
+
+ ) : null} + + {desktopSidebarView === "source-control" ? ( +
+ +
+ +
+
+ ) : null}
diff --git a/packages/web/src/features/workspace/views/shared/explorer-panel.tsx b/packages/web/src/features/workspace/views/shared/explorer-panel.tsx new file mode 100644 index 00000000..f4e4aa02 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/explorer-panel.tsx @@ -0,0 +1,113 @@ +import { useAtomValue } from "jotai"; +import { ChevronsUp } from "lucide-react"; +import type { FC } from "react"; +import { useState } from "react"; +import { IconButton, ThemedIcon, Tooltip } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { PanelHeader } from "../../../shared/components/panel-header"; +import type { WorkspaceCreateRequest } from "../../actions/use-workspace-screen-model"; +import { activeFilePathAtomFamily, openFilesAtomFamily } from "../../atoms"; +import { FileTreePanel } from "./file-tree-panel"; + +interface ExplorerPanelProps { + workspaceId: string; + createRequest: WorkspaceCreateRequest | null; + onCreateRequestConsumed: () => void; + onOpenFileCreate: () => void; + onOpenFolderCreate: () => void; +} + +export const ExplorerPanel: FC = ({ + workspaceId, + createRequest, + onCreateRequestConsumed, + onOpenFileCreate, + onOpenFolderCreate, +}) => { + const t = useTranslation(); + const { openLocation } = useOpenLocation(workspaceId); + const openFiles = useAtomValue(openFilesAtomFamily(workspaceId)); + const activeFilePath = useAtomValue(activeFilePathAtomFamily(workspaceId)); + const [collapseVersion, setCollapseVersion] = useState(0); + const openEditorPaths = Object.keys(openFiles).sort((left, right) => left.localeCompare(right)); + + return ( +
+ + + } + onClick={onOpenFileCreate} + size="sm" + /> + + + } + onClick={onOpenFolderCreate} + size="sm" + /> + + + } + onClick={() => setCollapseVersion((value) => value + 1)} + size="sm" + /> + +
+ } + /> + +
+
+

+ {t("workspace.sidebar.open_editors")} +

+
+ {openEditorPaths.map((path) => ( + + ))} +
+
+ +
+

{t("workspace.sidebar.workspace")}

+ +
+
+ + ); +}; diff --git a/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx b/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx index 271051a5..7642145d 100644 --- a/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx @@ -2012,6 +2012,21 @@ describe("FileTreePanel", () => { expect(document.querySelector(".tree-item-actions")).toBeNull(); }); + it("omits the desktop filename search input when showSearch is false", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + render( + + + + ); + + expect(screen.queryByLabelText("action.search_files")).toBeNull(); + expect(document.querySelector(".file-tree-search")).toBeNull(); + }); + it("opens the mobile action sheet on long press but not on ordinary tap", async () => { const sendCommand = vi.fn().mockResolvedValue({ ok: true }); const store = createStore(); diff --git a/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx b/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx index d06f523c..8fba6694 100644 --- a/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/file-tree-panel.tsx @@ -104,6 +104,7 @@ interface FileTreePanelProps { onVisibleCountChange?: (count: number, loading: boolean) => void; collapseVersion?: number; variant?: "desktop" | "mobile"; + showSearch?: boolean; } function normalizeExpandedDirs(paths: Iterable): string[] { @@ -121,6 +122,7 @@ export const FileTreePanel: FC = ({ onVisibleCountChange, collapseVersion = 0, variant = "desktop", + showSearch = true, }) => { const t = useTranslation(); const workspace = useAtomValue(workspaceByIdAtomFamily(workspaceId)); @@ -316,26 +318,28 @@ export const FileTreePanel: FC = ({ return ( <>
- + {showSearch ? ( + + ) : null}
{hasSearch ? ( diff --git a/packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx b/packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx new file mode 100644 index 00000000..5c28b87a --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx @@ -0,0 +1,49 @@ +import { FolderTree, GitBranch, Search } from "lucide-react"; +import type { FC } from "react"; +import { useTranslation } from "../../../../lib/i18n"; +import type { DesktopSidebarView } from "../../atoms/layout"; + +interface WorkspaceActivityBarProps { + activeView: DesktopSidebarView; + onSelectView: (view: DesktopSidebarView) => void; +} + +export const WorkspaceActivityBar: FC = ({ + activeView, + onSelectView, +}) => { + const t = useTranslation(); + const items: Array<{ + view: DesktopSidebarView; + label: string; + icon: typeof FolderTree; + }> = [ + { view: "explorer", label: t("workspace.sidebar.explorer"), icon: FolderTree }, + { view: "search", label: t("workspace.sidebar.search"), icon: Search }, + { + view: "source-control", + label: t("workspace.sidebar.source_control"), + icon: GitBranch, + }, + ]; + + return ( + + ); +}; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 98d20f99..69782b17 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -154,6 +154,17 @@ "loading_description": "Preparing your workspace list and restoring the last active session.", "load_failed_title": "Failed to load workspaces", "load_failed_description": "Failed to fetch workspace list", + "sidebar": { + "label": "Workspace activity bar", + "explorer": "Explorer", + "search": "Search", + "source_control": "Source Control", + "open_editors": "Open Editors", + "workspace": "Workspace" + }, + "search": { + "empty": "Search will appear here soon." + }, "launch": { "kicker": "START WORKSPACE", "title": "Open Workspace", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 5e3869c8..48478210 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -154,6 +154,17 @@ "loading_description": "正在准备工作区列表并恢复上次活跃的会话。", "load_failed_title": "工作区加载失败", "load_failed_description": "获取工作区列表失败", + "sidebar": { + "label": "工作区活动栏", + "explorer": "资源管理器", + "search": "搜索", + "source_control": "源代码管理", + "open_editors": "打开的编辑器", + "workspace": "工作区" + }, + "search": { + "empty": "搜索功能即将显示在这里。" + }, "launch": { "kicker": "启动工作区", "title": "打开工作区", diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 9e9403f6..81ef9842 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -12320,50 +12320,71 @@ textarea.input { display: flex; min-height: 0; height: 100%; - flex-direction: column; + flex-direction: row; background: var(--bg-panel); } -.workspace-sidebar-panel__tabs { +.workspace-activity-bar { display: flex; + width: 52px; + flex-direction: column; align-items: center; - gap: var(--gap-default); - min-width: 0; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-2); + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-panel) 88%, var(--bg-page)); } -.workspace-sidebar-panel__tab { - position: relative; +.workspace-activity-bar__button { display: inline-flex; + width: 100%; + min-height: 40px; align-items: center; - gap: var(--gap-control); - min-height: 24px; - padding: 0; + justify-content: center; border: none; + border-radius: var(--radius-lg); background: transparent; color: var(--text-tertiary); - font-size: var(--type-body-6-size); - line-height: var(--type-body-6-line-height); - font-weight: var(--type-body-6-weight); - transition: color var(--duration-fast) var(--ease-out); + transition: + background-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); } -.workspace-sidebar-panel__tab:hover { +.workspace-activity-bar__button:hover { + background: var(--bg-hover); color: var(--text-secondary); } -.workspace-sidebar-panel__tab.active { +.workspace-activity-bar__button--active { + background: color-mix(in srgb, var(--accent-blue) 14%, transparent); color: var(--text-primary); } -.workspace-sidebar-panel__tab.active::after { - content: ""; +.workspace-activity-bar__label { position: absolute; - right: 0; - bottom: -8px; - left: 0; - height: 1.5px; - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--accent-blue) 90%, white 10%); + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.workspace-sidebar-panel__content { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; +} + +.workspace-sidebar-view { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; } .workspace-sidebar-panel__actions { @@ -12380,6 +12401,59 @@ textarea.input { flex: 1; } +.workspace-sidebar-panel__body--stacked { + flex-direction: column; +} + +.workspace-sidebar-section { + display: flex; + min-height: 0; + flex-direction: column; + padding: var(--sp-3) var(--sp-3) 0; +} + +.workspace-sidebar-section--fill { + flex: 1; + padding-bottom: var(--sp-3); +} + +.workspace-sidebar-section__title { + margin: 0 0 var(--sp-2); + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.workspace-open-editors { + display: flex; + flex-direction: column; + gap: 2px; +} + +.workspace-open-editors__item { + width: 100%; + min-height: 28px; + padding: 0 var(--sp-2); + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + text-align: left; +} + +.workspace-open-editors__item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.workspace-open-editors__item--active { + background: var(--state-selected-bg); + color: var(--text-primary); +} + .workspace-sidebar-panel .panel-toolbar-btn { width: 24px; height: 24px; From 94aa482374e117bfbcc52b0b32bb9682905f0d14 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:09:33 +0800 Subject: [PATCH 02/41] feat(workspace): add sidebar search and quick open --- packages/core/src/domain/types.ts | 24 ++ .../src/__tests__/file-commands.test.ts | 45 +++ .../src/__tests__/fs/content-search.test.ts | 186 ++++++++++ packages/server/src/commands/file.ts | 24 ++ packages/server/src/fs/content-search.ts | 333 ++++++++++++++++++ packages/web/src/atoms/app-ui.ts | 5 + .../components/command-palette.test.tsx | 47 ++- .../components/command-palette.tsx | 17 +- .../quick-open/components/quick-open.test.tsx | 114 ++++++ .../quick-open/components/quick-open.tsx | 218 ++++++++++++ .../web/src/features/quick-open/index.tsx | 1 + .../src/features/workspace/atoms/layout.ts | 31 +- .../web/src/features/workspace/index.test.tsx | 170 +++++++++ .../views/desktop/workspace-desktop-view.tsx | 38 +- .../workspace/views/shared/explorer-panel.tsx | 1 + .../views/shared/search-panel.test.tsx | 162 +++++++++ .../workspace/views/shared/search-panel.tsx | 192 ++++++++++ packages/web/src/locales/en.json | 18 +- packages/web/src/locales/zh.json | 18 +- .../web/src/shells/desktop-shell.test.tsx | 34 ++ packages/web/src/shells/desktop-shell.tsx | 2 + packages/web/src/styles/components.css | 2 +- .../web/src/styles/components.theme.test.ts | 20 +- 23 files changed, 1676 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/__tests__/fs/content-search.test.ts create mode 100644 packages/server/src/fs/content-search.ts create mode 100644 packages/web/src/features/quick-open/components/quick-open.test.tsx create mode 100644 packages/web/src/features/quick-open/components/quick-open.tsx create mode 100644 packages/web/src/features/quick-open/index.tsx create mode 100644 packages/web/src/features/workspace/views/shared/search-panel.test.tsx create mode 100644 packages/web/src/features/workspace/views/shared/search-panel.tsx diff --git a/packages/core/src/domain/types.ts b/packages/core/src/domain/types.ts index b6a11a3a..a464d1b4 100644 --- a/packages/core/src/domain/types.ts +++ b/packages/core/src/domain/types.ts @@ -151,6 +151,30 @@ export interface FileNode { mtime?: number; } +export interface SearchContentMatch { + line: number; + column: number; + endColumn: number; + preview: string; + previewColumnStart: number; + previewColumnEnd: number; +} + +export interface SearchContentFileResult { + path: string; + name: string; + matchCount: number; + hasMoreMatches: boolean; + matches: SearchContentMatch[]; +} + +export interface SearchContentResult { + files: SearchContentFileResult[]; + totalMatchCount: number; + hasMoreFiles: boolean; + truncatedMatchFileCount: number; +} + export interface Settings { defaultProviderId: string; notifications: { diff --git a/packages/server/src/__tests__/file-commands.test.ts b/packages/server/src/__tests__/file-commands.test.ts index e4ec0259..dcd5c726 100644 --- a/packages/server/src/__tests__/file-commands.test.ts +++ b/packages/server/src/__tests__/file-commands.test.ts @@ -142,6 +142,51 @@ describe("File Commands", () => { expect(files).toHaveLength(0); }); + it("dispatches file.searchContent and returns grouped content matches", async () => { + await writeFile(join(testDir, "alpha.ts"), "const hit = 'match';\nconst second = 'match';\n"); + await writeFile(join(testDir, "notes.md"), "match in docs\n"); + + const result = await dispatch( + { + kind: "command", + id: "file-search-content-1", + op: "file.searchContent", + args: { + workspaceId, + query: "match", + maxFiles: 1, + maxMatchesPerFile: 1, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + totalMatchCount: 3, + hasMoreFiles: true, + truncatedMatchFileCount: 1, + files: [ + { + path: "alpha.ts", + name: "alpha.ts", + matchCount: 2, + hasMoreMatches: true, + matches: [ + { + line: 1, + column: 14, + endColumn: 19, + preview: "const hit = 'match';", + previewColumnStart: 14, + previewColumnEnd: 19, + }, + ], + }, + ], + }); + }); + it("shows dotfiles and node_modules in file.readTree while still hiding .git", async () => { await writeFile(join(testDir, ".gitignore"), "*.log\nnode_modules/\n"); await writeFile(join(testDir, ".env"), "secret\n"); diff --git a/packages/server/src/__tests__/fs/content-search.test.ts b/packages/server/src/__tests__/fs/content-search.test.ts new file mode 100644 index 00000000..ae0ea1ee --- /dev/null +++ b/packages/server/src/__tests__/fs/content-search.test.ts @@ -0,0 +1,186 @@ +import { execFile as execFileCallback } from "child_process"; +import { mkdir, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { promisify } from "util"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { searchFileContents } from "../../fs/content-search.js"; + +const execFile = promisify(execFileCallback); + +describe("searchFileContents", () => { + let testDir: string; + + beforeEach(async () => { + testDir = join( + tmpdir(), + `content-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(testDir, { recursive: true }); + + await execFile("git", ["init"], { cwd: testDir }); + await execFile("git", ["config", "user.name", "Test"], { cwd: testDir }); + await execFile("git", ["config", "user.email", "test@example.com"], { cwd: testDir }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("groups matches by file and returns preview highlight metadata", async () => { + await writeFile( + join(testDir, "alpha.ts"), + [ + "const alpha = 'alpha';", + "const beta = alpha + '-match';", + "const gamma = 'done';", + "", + ].join("\n") + ); + await writeFile(join(testDir, "notes.md"), "match on the first line\n"); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.totalMatchCount).toBe(2); + expect(result.hasMoreFiles).toBe(false); + expect(result.truncatedMatchFileCount).toBe(0); + expect(result.files.map((file) => file.path)).toEqual(["alpha.ts", "notes.md"]); + + expect(result.files[0]).toMatchObject({ + path: "alpha.ts", + name: "alpha.ts", + matchCount: 1, + hasMoreMatches: false, + }); + expect(result.files[0]?.matches).toEqual([ + { + line: 2, + column: 24, + endColumn: 29, + preview: "const beta = alpha + '-match';", + previewColumnStart: 24, + previewColumnEnd: 29, + }, + ]); + expect(result.files[1]?.matches[0]).toMatchObject({ + line: 1, + column: 1, + endColumn: 6, + preview: "match on the first line", + previewColumnStart: 1, + previewColumnEnd: 6, + }); + }); + + it("falls back to Node scanner when rg unavailable", async () => { + await writeFile(join(testDir, "fallback.txt"), "plain text fallback match\n"); + vi.stubEnv("PATH", ""); + + const result = await searchFileContents(testDir, { + query: "fallback", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toMatchObject({ + path: "fallback.txt", + matchCount: 1, + hasMoreMatches: false, + }); + expect(result.totalMatchCount).toBe(1); + }); + + it("reports Unicode-aware columns from the ripgrep path", async () => { + await writeFile(join(testDir, "unicode.txt"), "cafematch\ncafematch\ncafematch\n"); + await writeFile(join(testDir, "utf8.txt"), "咖啡match\n"); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files.find((file) => file.path === "utf8.txt")?.matches[0]).toMatchObject({ + line: 1, + column: 3, + endColumn: 8, + previewColumnStart: 3, + previewColumnEnd: 8, + }); + }); + + it("keeps .gitignore filtering on the ripgrep path outside a git repository", async () => { + const plainDir = join( + tmpdir(), + `content-search-plain-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(plainDir, { recursive: true }); + + try { + await writeFile(join(plainDir, ".gitignore"), "ignored.txt\n"); + await writeFile(join(plainDir, "ignored.txt"), "match\n"); + await writeFile(join(plainDir, "visible.txt"), "match\n"); + + const result = await searchFileContents(plainDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files.map((file) => file.path)).toEqual(["visible.txt"]); + } finally { + await rm(plainDir, { recursive: true, force: true }); + } + }); + + it("respects .gitignore, skips binary files, and reports truncation", async () => { + await writeFile(join(testDir, ".gitignore"), "ignored.txt\n"); + await writeFile(join(testDir, "ignored.txt"), "match should be hidden\n"); + await writeFile(join(testDir, "first.txt"), "match one\nmatch two\nmatch three\n"); + await writeFile(join(testDir, "second.txt"), "match four\n"); + await writeFile( + join(testDir, "binary.bin"), + Buffer.from([0x00, 0x01, 0x6d, 0x61, 0x74, 0x63, 0x68]) + ); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 1, + maxMatchesPerFile: 2, + }); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toMatchObject({ + path: "first.txt", + matchCount: 3, + hasMoreMatches: true, + }); + expect(result.files[0]?.matches).toHaveLength(2); + expect(result.totalMatchCount).toBe(4); + expect(result.hasMoreFiles).toBe(true); + expect(result.truncatedMatchFileCount).toBe(1); + expect(result.files.some((file) => file.path === "ignored.txt")).toBe(false); + expect(result.files.some((file) => file.path === "binary.bin")).toBe(false); + }); + + it("skips oversized files in the Node fallback scanner", async () => { + await writeFile(join(testDir, "large.txt"), "x".repeat(1_000_001) + "match"); + await writeFile(join(testDir, "small.txt"), "match\n"); + vi.stubEnv("PATH", ""); + + const result = await searchFileContents(testDir, { + query: "match", + maxFiles: 10, + maxMatchesPerFile: 10, + }); + + expect(result.files.map((file) => file.path)).toEqual(["small.txt"]); + }); +}); diff --git a/packages/server/src/commands/file.ts b/packages/server/src/commands/file.ts index 1d2dc2f4..2cb5a565 100644 --- a/packages/server/src/commands/file.ts +++ b/packages/server/src/commands/file.ts @@ -3,6 +3,7 @@ */ import { z } from "zod"; +import { searchFileContents } from "../fs/content-search.js"; import { createDirectory, createFile, @@ -49,6 +50,29 @@ registerCommand( } ); +// file.searchContent +registerCommand( + "file.searchContent", + z.object({ + workspaceId: z.string(), + query: z.string(), + maxFiles: z.number().int().positive().max(100), + maxMatchesPerFile: z.number().int().positive().max(100), + }), + async (args, ctx) => { + const workspace = ctx.workspaceMgr.get(args.workspaceId); + if (!workspace) { + throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; + } + + return searchFileContents(workspace.path, { + query: args.query, + maxFiles: args.maxFiles, + maxMatchesPerFile: args.maxMatchesPerFile, + }); + } +); + // file.read registerCommand( "file.read", diff --git a/packages/server/src/fs/content-search.ts b/packages/server/src/fs/content-search.ts new file mode 100644 index 00000000..fc3d2401 --- /dev/null +++ b/packages/server/src/fs/content-search.ts @@ -0,0 +1,333 @@ +import type { + SearchContentFileResult, + SearchContentMatch, + SearchContentResult, +} from "@coder-studio/core"; +import { spawn } from "child_process"; +import { existsSync } from "fs"; +import { readdir, readFile, stat } from "fs/promises"; +import { basename, join, relative } from "path"; +import { createInterface } from "readline"; +import { createGitignoreFilter } from "./gitignore.js"; + +const FALLBACK_MAX_FILE_BYTES = 1_000_000; + +export interface SearchFileContentsOptions { + query: string; + maxFiles: number; + maxMatchesPerFile: number; +} + +interface FileAccumulator { + path: string; + name: string; + matches: SearchContentMatch[]; + matchCount: number; +} + +interface SearchAccumulatorResult { + files: FileAccumulator[]; + totalMatchCount: number; + hasMoreFiles: boolean; +} + +export async function searchFileContents( + rootPath: string, + options: SearchFileContentsOptions +): Promise { + const query = options.query.trim(); + if (!query) { + return { + files: [], + totalMatchCount: 0, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + }; + } + + const result = await searchWithRipgrep(rootPath, query, options.maxFiles).catch( + async (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + return searchWithNode(rootPath, query, options.maxFiles); + } + + throw error; + } + ); + + return finalizeResults(result, options.maxFiles, options.maxMatchesPerFile); +} + +async function searchWithRipgrep( + rootPath: string, + query: string, + maxFiles: number +): Promise { + const hasGitignore = existsSync(join(rootPath, ".gitignore")); + const args = [ + "--json", + "--line-number", + "--column", + "--fixed-strings", + "--sort", + "path", + "--with-filename", + "--glob", + "!**/.git/**", + "--glob", + "!**/node_modules/**", + ]; + + if (hasGitignore) { + args.push("--hidden"); + args.push("--no-require-git"); + } + + args.push(query, "."); + + return new Promise((resolve, reject) => { + const child = spawn("rg", args, { cwd: rootPath, stdio: ["ignore", "pipe", "pipe"] }); + const stdout = createInterface({ input: child.stdout }); + const files = new Map(); + let totalMatchCount = 0; + let hasMoreFiles = false; + let stderr = ""; + + stdout.on("line", (line) => { + if (!line.trim()) { + return; + } + + const event = JSON.parse(line) as { + type?: string; + data?: { + path?: { text?: string }; + line_number?: number; + lines?: { text?: string }; + submatches?: Array<{ start: number; end: number }>; + }; + }; + + if (event.type !== "match") { + return; + } + + const rawPath = event.data?.path?.text; + if (!rawPath) { + return; + } + + const relativePath = normalizeRelativePath(relative(rootPath, join(rootPath, rawPath))); + const preview = (event.data?.lines?.text ?? "").replace(/\r?\n$/, ""); + const lineNumber = event.data?.line_number ?? 1; + const submatches = event.data?.submatches ?? []; + + totalMatchCount += submatches.length; + + if (!files.has(relativePath) && files.size >= maxFiles) { + hasMoreFiles = true; + return; + } + + for (const submatch of submatches) { + pushMatch(files, relativePath, { + line: lineNumber, + column: byteOffsetToColumn(preview, submatch.start), + endColumn: byteOffsetToColumn(preview, submatch.end), + preview, + previewColumnStart: byteOffsetToColumn(preview, submatch.start), + previewColumnEnd: byteOffsetToColumn(preview, submatch.end), + }); + } + }); + + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + void stdout.close(); + reject(error); + }); + + child.on("close", (code) => { + void stdout.close(); + + if (code === 0 || code === 1) { + resolve({ + files: sortAccumulators(files), + totalMatchCount, + hasMoreFiles, + }); + return; + } + + reject( + Object.assign(new Error(stderr || `rg exited with code ${code ?? "unknown"}`), { code }) + ); + }); + }); +} + +async function searchWithNode( + rootPath: string, + query: string, + maxFiles: number +): Promise { + const files = new Map(); + let totalMatchCount = 0; + let hasMoreFiles = false; + + async function walk(dirPath: string): Promise { + const filter = createGitignoreFilter(rootPath, dirPath); + const entries = await readdir(dirPath, { withFileTypes: true }); + const filteredEntries = entries.filter((entry) => filter(entry.name)); + filteredEntries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of filteredEntries) { + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const fileStat = await stat(fullPath); + if (fileStat.size > FALLBACK_MAX_FILE_BYTES) { + continue; + } + + const buffer = await readFile(fullPath); + if (isBinaryFile(buffer)) { + continue; + } + + const relativePath = normalizeRelativePath(relative(rootPath, fullPath)); + const file = collectMatchesFromText(relativePath, buffer.toString("utf-8"), query); + if (!file) { + continue; + } + + totalMatchCount += file.matchCount; + + if (files.size >= maxFiles) { + hasMoreFiles = true; + continue; + } + + files.set(relativePath, file); + } + } + + await walk(rootPath); + return { + files: sortAccumulators(files), + totalMatchCount, + hasMoreFiles, + }; +} + +function collectMatchesFromText( + relativePath: string, + content: string, + query: string +): FileAccumulator | null { + const file: FileAccumulator = { + path: relativePath, + name: basename(relativePath), + matches: [], + matchCount: 0, + }; + const lines = content.split(/\r?\n/); + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const preview = lines[lineIndex] ?? ""; + if (!preview) { + continue; + } + + let fromIndex = 0; + while (fromIndex <= preview.length) { + const matchIndex = preview.indexOf(query, fromIndex); + if (matchIndex === -1) { + break; + } + + const startColumn = matchIndex + 1; + const endColumn = startColumn + query.length; + file.matches.push({ + line: lineIndex + 1, + column: startColumn, + endColumn, + preview, + previewColumnStart: startColumn, + previewColumnEnd: endColumn, + }); + file.matchCount += 1; + fromIndex = matchIndex + Math.max(query.length, 1); + } + } + + return file.matchCount > 0 ? file : null; +} + +function pushMatch( + files: Map, + relativePath: string, + match: SearchContentMatch +): void { + let file = files.get(relativePath); + if (!file) { + file = { + path: relativePath, + name: basename(relativePath), + matches: [], + matchCount: 0, + }; + files.set(relativePath, file); + } + + file.matches.push(match); + file.matchCount += 1; +} + +function sortAccumulators(files: Map): FileAccumulator[] { + return Array.from(files.values()).sort((a, b) => a.path.localeCompare(b.path)); +} + +function finalizeResults( + result: SearchAccumulatorResult, + maxFiles: number, + maxMatchesPerFile: number +): SearchContentResult { + const visibleFiles = result.files.slice(0, maxFiles).map((file) => ({ + path: file.path, + name: file.name, + matchCount: file.matchCount, + hasMoreMatches: file.matchCount > maxMatchesPerFile, + matches: file.matches.slice(0, maxMatchesPerFile), + })); + + return { + files: visibleFiles, + totalMatchCount: result.totalMatchCount, + hasMoreFiles: result.hasMoreFiles || result.files.length > maxFiles, + truncatedMatchFileCount: visibleFiles.filter((file) => file.hasMoreMatches).length, + }; +} + +function normalizeRelativePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function byteOffsetToColumn(preview: string, byteOffset: number): number { + return Buffer.from(preview, "utf8").subarray(0, byteOffset).toString("utf8").length + 1; +} + +function isBinaryFile(buffer: Buffer): boolean { + const sample = buffer.subarray(0, 8000); + return sample.includes(0); +} diff --git a/packages/web/src/atoms/app-ui.ts b/packages/web/src/atoms/app-ui.ts index 26745e1a..290bf4cf 100644 --- a/packages/web/src/atoms/app-ui.ts +++ b/packages/web/src/atoms/app-ui.ts @@ -72,6 +72,11 @@ export const authenticatedAtom = atom(false); */ export const commandPaletteOpenAtom = atom(false); +/** + * Quick Open overlay state + */ +export const quickOpenOpenAtom = atom(false); + /** * Pending session-focus request. * diff --git a/packages/web/src/features/command-palette/components/command-palette.test.tsx b/packages/web/src/features/command-palette/components/command-palette.test.tsx index cbac67a7..15079f89 100644 --- a/packages/web/src/features/command-palette/components/command-palette.test.tsx +++ b/packages/web/src/features/command-palette/components/command-palette.test.tsx @@ -2,7 +2,7 @@ import type { Workspace } from "@coder-studio/core"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { commandPaletteOpenAtom, localeAtom } from "../../../atoms/app-ui"; +import { commandPaletteOpenAtom, localeAtom, quickOpenOpenAtom } from "../../../atoms/app-ui"; import { wsClientAtom } from "../../../atoms/connection"; import { activeWorkspaceIdAtom, @@ -233,6 +233,51 @@ describe("CommandPalette", () => { expect(store.get(terminalPanelVisibleAtom)).toBe(true); }); + it("opens Quick Open from the quick actions list on desktop", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + store.set(activeWorkspaceIdAtom, "ws-1"); + + render( + + + + ); + + fireEvent.click(screen.getByText("Go to File...")); + + expect(store.get(quickOpenOpenAtom)).toBe(true); + expect(store.get(commandPaletteOpenAtom)).toBe(false); + }); + + it("hides Go to File on mobile", () => { + viewportMocks.viewport = "mobile"; + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + store.set(activeWorkspaceIdAtom, "ws-1"); + + render( + + + + ); + + expect(screen.queryByText("Go to File...")).toBeNull(); + }); + it("keeps the desktop palette inside the shared workbench layer", () => { const store = createStore(); store.set(localeAtom, "en"); diff --git a/packages/web/src/features/command-palette/components/command-palette.tsx b/packages/web/src/features/command-palette/components/command-palette.tsx index b6989816..8459dc5d 100644 --- a/packages/web/src/features/command-palette/components/command-palette.tsx +++ b/packages/web/src/features/command-palette/components/command-palette.tsx @@ -8,7 +8,7 @@ import type { Workspace } from "@coder-studio/core"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { commandPaletteOpenAtom } from "../../../atoms/app-ui"; +import { commandPaletteOpenAtom, quickOpenOpenAtom } from "../../../atoms/app-ui"; import { activeWorkspaceIdAtom, orderedWorkspacesAtom, @@ -71,6 +71,7 @@ export function CommandPalette() { const [bottomPanelHeight, setBottomPanelHeight] = useAtom(bottomPanelHeightAtom); const activeWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); const setActiveWorkspaceId = useSetAtom(activeWorkspaceIdAtom); + const setQuickOpenOpen = useSetAtom(quickOpenOpenAtom); const selectWorkspaceTarget = useSelectWorkspaceTarget(); const workspaces = useAtomValue(orderedWorkspacesAtom); @@ -97,6 +98,7 @@ export function CommandPalette() { locationPathname: location.pathname, navigate, t, + setQuickOpenOpen, setShowWorkspaceLaunch: (nextValue) => { if (nextValue) { setIsOpen(false); @@ -295,6 +297,7 @@ function buildCommands(context: { locationPathname: string; navigate: (path: string) => void; t: (key: string) => string; + setQuickOpenOpen: (value: boolean) => void; setShowWorkspaceLaunch: (v: boolean) => void; }): Command[] { const { @@ -314,6 +317,7 @@ function buildCommands(context: { locationPathname, navigate, t, + setQuickOpenOpen, setShowWorkspaceLaunch, } = context; @@ -346,6 +350,17 @@ function buildCommands(context: { if (shellKind === "desktop") { commands.push( + ...(activeWorkspaceId + ? [ + { + id: "go-to-file", + label: t("quick_open.command_label"), + description: t("quick_open.command_description"), + shortcut: "Ctrl+P", + action: () => setQuickOpenOpen(true), + } satisfies Command, + ] + : []), { id: "toggle-focus-mode", label: t("tooltip.focus_mode"), diff --git a/packages/web/src/features/quick-open/components/quick-open.test.tsx b/packages/web/src/features/quick-open/components/quick-open.test.tsx new file mode 100644 index 00000000..823ce533 --- /dev/null +++ b/packages/web/src/features/quick-open/components/quick-open.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { quickOpenOpenAtom } from "../../../atoms/app-ui"; +import { wsClientAtom } from "../../../atoms/connection"; +import { + activeWorkspaceIdAtom, + workspaceOrderAtom, + workspacesAtom, + workspacesLoadStateAtom, +} from "../../../atoms/workspaces"; +import { activeFilePathAtomFamily } from "../../workspace/atoms/files"; +import { QuickOpen } from "./quick-open"; + +function seedWorkspace(store: ReturnType) { + store.set(workspacesAtom, { + "ws-test": { + id: "ws-test", + path: "/workspace", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + } as never); + store.set(workspaceOrderAtom, ["ws-test"]); + store.set(activeWorkspaceIdAtom, "ws-test"); + store.set(workspacesLoadStateAtom, "ready"); +} + +describe("QuickOpen", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("opens on Ctrl/Cmd+P and queries file.search for the active workspace", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + + render( + + + + ); + + fireEvent.keyDown(window, { key: "p", ctrlKey: true }); + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "file.search", + { + workspaceId: "ws-test", + query: "app", + limit: 25, + }, + undefined + ); + + expect(screen.getByText("app.tsx")).toBeInTheDocument(); + }); + + it("opens the selected file and closes after Enter", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + store.set(quickOpenOpenAtom, true); + + render( + + + + ); + + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + expect(screen.getByText("app.tsx")).toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + key: "Enter", + }); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(quickOpenOpenAtom)).toBe(false); + }); +}); diff --git a/packages/web/src/features/quick-open/components/quick-open.tsx b/packages/web/src/features/quick-open/components/quick-open.tsx new file mode 100644 index 00000000..863925f6 --- /dev/null +++ b/packages/web/src/features/quick-open/components/quick-open.tsx @@ -0,0 +1,218 @@ +import type { FileNode } from "@coder-studio/core"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { quickOpenOpenAtom } from "../../../atoms/app-ui"; +import { dispatchCommandAtom } from "../../../atoms/connection"; +import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; +import { EmptyState, ThemedIcon, WorkbenchLayer } from "../../../components/ui"; +import { useTranslation } from "../../../lib/i18n"; +import { useOpenLocation } from "../../code-editor/actions/use-open-location"; + +interface SearchFilesResult { + files: FileNode[]; +} + +export function QuickOpen() { + const t = useTranslation(); + const [open, setOpen] = useAtom(quickOpenOpenAtom); + const workspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId ?? "__workspace_placeholder__"); + const inputRef = useRef(null); + const dispatchRef = useRef(dispatch); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const failedLabel = t("quick_open.failed"); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p") { + event.preventDefault(); + setOpen(true); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [setOpen]); + + useEffect(() => { + if (!open) { + return; + } + + inputRef.current?.focus(); + setQuery(""); + setSelectedIndex(0); + setResults([]); + setError(null); + }, [open]); + + useEffect(() => { + dispatchRef.current = dispatch; + }, [dispatch]); + + useEffect(() => { + if (!open || !workspaceId) { + return; + } + + const trimmed = query.trim(); + if (!trimmed) { + setResults([]); + setLoading(false); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + const timeout = window.setTimeout(() => { + void dispatchRef + .current("file.search", { + workspaceId, + query: trimmed, + limit: 25, + }) + .then((result) => { + if (cancelled) { + return; + } + + if (!result.ok || !result.data) { + setResults([]); + setError(failedLabel); + return; + } + + setResults(result.data.files); + setSelectedIndex(0); + }) + .catch(() => { + if (!cancelled) { + setResults([]); + setError(failedLabel); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 150); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [failedLabel, open, query, workspaceId]); + + if (!open) { + return null; + } + + const activeResult = results[selectedIndex] ?? null; + + return ( + inputRef.current} + onOpenChange={setOpen} + open + > +
{ + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, Math.max(results.length - 1, 0))); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + return; + } + + if (event.key === "Enter" && activeResult && workspaceId) { + event.preventDefault(); + void openLocation({ + workspaceId, + path: activeResult.path, + source: "manual", + }); + setOpen(false); + } + }} + > +
+ + setQuery(event.target.value)} + /> +
+ +
+ {!workspaceId ? ( + {t("workspace.no_workspace")}

} + /> + ) : error ? ( +

{error}

+ ) : !query.trim() ? ( +

{t("quick_open.empty")}

+ ) : loading ? ( +

{t("common.loading")}

+ ) : results.length === 0 ? ( +

{t("quick_open.no_results")}

+ ) : ( + results.map((file, index) => ( + + )) + )} +
+
+
+ ); +} diff --git a/packages/web/src/features/quick-open/index.tsx b/packages/web/src/features/quick-open/index.tsx new file mode 100644 index 00000000..66647ad7 --- /dev/null +++ b/packages/web/src/features/quick-open/index.tsx @@ -0,0 +1 @@ +export { QuickOpen } from "./components/quick-open"; diff --git a/packages/web/src/features/workspace/atoms/layout.ts b/packages/web/src/features/workspace/atoms/layout.ts index 642ba1ce..2174c0b6 100644 --- a/packages/web/src/features/workspace/atoms/layout.ts +++ b/packages/web/src/features/workspace/atoms/layout.ts @@ -4,9 +4,32 @@ * Persisted UI state owned by the workspace feature. */ -import { atomWithStorage } from "jotai/utils"; +import { atomWithStorage, createJSONStorage } from "jotai/utils"; export type DesktopSidebarView = "explorer" | "search" | "source-control"; +const DEFAULT_DESKTOP_SIDEBAR_VIEW: DesktopSidebarView = "explorer"; +const DESKTOP_SIDEBAR_VIEW_VALUES = new Set([ + "explorer", + "search", + "source-control", +]); + +export function sanitizeDesktopSidebarView(value: unknown): DesktopSidebarView { + return typeof value === "string" && DESKTOP_SIDEBAR_VIEW_VALUES.has(value as DesktopSidebarView) + ? (value as DesktopSidebarView) + : DEFAULT_DESKTOP_SIDEBAR_VIEW; +} + +const baseDesktopSidebarStorage = createJSONStorage(() => window.localStorage); +const desktopSidebarStorage = { + ...baseDesktopSidebarStorage, + getItem: (_key: string, initialValue: DesktopSidebarView) => + sanitizeDesktopSidebarView( + baseDesktopSidebarStorage.getItem("ui.desktopSidebarView", initialValue) + ), + setItem: (key: string, value: DesktopSidebarView) => + baseDesktopSidebarStorage.setItem(key, sanitizeDesktopSidebarView(value)), +}; /** * Focus mode toggle (hides left/bottom panels) @@ -37,7 +60,11 @@ export const sidebarCollapsedAtom = atomWithStorage("ui.sidebarCollapsed", false */ export const desktopSidebarViewAtom = atomWithStorage( "ui.desktopSidebarView", - "explorer" + DEFAULT_DESKTOP_SIDEBAR_VIEW, + desktopSidebarStorage, + { + getOnInit: true, + } ); /** diff --git a/packages/web/src/features/workspace/index.test.tsx b/packages/web/src/features/workspace/index.test.tsx index 2114b314..fa52cf41 100644 --- a/packages/web/src/features/workspace/index.test.tsx +++ b/packages/web/src/features/workspace/index.test.tsx @@ -471,6 +471,176 @@ describe("WorkspacePage", () => { expect(screen.getByTestId("git-panel")).toBeInTheDocument(); }); + it("renders the content search input when the Search activity item is active", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + if (op === "file.searchContent") { + return { + files: [], + totalMatchCount: 0, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /Search|搜索/i })); + expect(await screen.findByRole("searchbox", { name: /Search|搜索/i })).toBeInTheDocument(); + }); + + it("falls back to Explorer when the persisted desktop sidebar view is invalid", async () => { + window.localStorage.setItem("ui.desktopSidebarView", JSON.stringify("legacy")); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + const explorerButton = await screen.findByRole("button", { name: /Explorer|资源管理器/i }); + expect(explorerButton).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByTestId("file-tree-panel")).toBeInTheDocument(); + }); + + it("ignores sidebar shortcuts while focus is inside an editable field", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + fireEvent.keyDown(input, { key: "3", ctrlKey: true }); + + expect(screen.queryByTestId("git-panel")).toBeNull(); + expect(screen.getByRole("button", { name: /Explorer|资源管理器/i })).toHaveAttribute( + "aria-pressed", + "true" + ); + + input.remove(); + }); + it("does not render a duplicate worktree entry button in the desktop git header", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { diff --git a/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx b/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx index a1604beb..4711c40f 100644 --- a/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx +++ b/packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx @@ -10,11 +10,25 @@ import { TopBar } from "../../../topbar"; import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen"; import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model"; import { sidebarCollapsedAtom } from "../../atoms"; +import { sanitizeDesktopSidebarView } from "../../atoms/layout"; import { ExplorerPanel } from "../shared/explorer-panel"; import { GitPanel } from "../shared/git-panel"; +import { SearchPanel } from "../shared/search-panel"; import { WorkspaceActivityBar } from "../shared/workspace-activity-bar"; import { WorkspaceStatusBar } from "../shared/workspace-status-bar"; +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target.isContentEditable || target.closest('[contenteditable="true"]')) { + return true; + } + + return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName); +} + export const WorkspaceDesktopView: FC = () => { const fullscreenRootRef = useRef(null); const fullscreenController = useWorkspaceFullscreen(fullscreenRootRef); @@ -41,8 +55,14 @@ export const WorkspaceDesktopView: FC = () => { bottomPanelRef, } = useWorkspaceScreenModel(); const setSidebarCollapsed = useSetAtom(sidebarCollapsedAtom); + const activeSidebarView = sanitizeDesktopSidebarView(desktopSidebarView); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || isEditableTarget(event.target)) { + return; + } + if (!(event.metaKey || event.ctrlKey)) { return; } @@ -104,12 +124,12 @@ export const WorkspaceDesktopView: FC = () => { >
- {desktopSidebarView === "explorer" ? ( + {activeSidebarView === "explorer" ? ( { /> ) : null} - {desktopSidebarView === "search" ? ( -
- -
- {t("workspace.search.empty")}

} - /> -
-
+ {activeSidebarView === "search" ? ( + ) : null} - {desktopSidebarView === "source-control" ? ( + {activeSidebarView === "source-control" ? (
diff --git a/packages/web/src/features/workspace/views/shared/explorer-panel.tsx b/packages/web/src/features/workspace/views/shared/explorer-panel.tsx index f4e4aa02..1707854a 100644 --- a/packages/web/src/features/workspace/views/shared/explorer-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/explorer-panel.tsx @@ -82,6 +82,7 @@ export const ExplorerPanel: FC = ({ className={`workspace-open-editors__item ${ activeFilePath === path ? "workspace-open-editors__item--active" : "" }`} + title={path} onClick={() => void openLocation({ workspaceId, diff --git a/packages/web/src/features/workspace/views/shared/search-panel.test.tsx b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx new file mode 100644 index 00000000..24e5a81f --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom + +import type { SearchContentResult } from "@coder-studio/core"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../../atoms/connection"; +import { pendingEditorNavigationAtomFamily } from "../../../code-editor/atoms"; +import { activeFilePathAtomFamily } from "../../atoms/files"; +import { SearchPanel } from "./search-panel"; + +describe("SearchPanel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("debounces content queries, renders grouped results, and highlights matches", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 2, + hasMoreMatches: true, + matches: [ + { + line: 3, + column: 7, + endColumn: 18, + preview: "const needleValue = searchState;", + previewColumnStart: 7, + previewColumnEnd: 18, + }, + { + line: 8, + column: 8, + endColumn: 19, + preview: "return needleValue;", + previewColumnStart: 8, + previewColumnEnd: 19, + }, + ], + }, + ], + totalMatchCount: 2, + hasMoreFiles: true, + truncatedMatchFileCount: 1, + } satisfies SearchContentResult); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "file.searchContent", + { + workspaceId: "ws-test", + query: "needle", + maxFiles: 50, + maxMatchesPerFile: 20, + }, + undefined + ); + + expect(screen.getByText("app.tsx")).toBeInTheDocument(); + expect(screen.getByText("src/app.tsx")).toBeInTheDocument(); + expect(screen.getAllByText("needleValue")[0]?.tagName).toBe("MARK"); + expect(screen.getByText(/Results limited|结果已截断/i)).toBeInTheDocument(); + }); + + it("opens the file at the selected match location", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + fireEvent.click(screen.getByRole("button", { name: /12.*needle/i })); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(pendingEditorNavigationAtomFamily("ws-test"))).toMatchObject({ + workspaceId: "ws-test", + path: "src/app.tsx", + line: 12, + column: 5, + source: "search", + }); + }); + + it("shows retry when the search command fails", async () => { + const sendCommand = vi.fn().mockRejectedValue(new Error("boom")); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(screen.getByRole("button", { name: /Retry|重试/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/features/workspace/views/shared/search-panel.tsx b/packages/web/src/features/workspace/views/shared/search-panel.tsx new file mode 100644 index 00000000..fa96b089 --- /dev/null +++ b/packages/web/src/features/workspace/views/shared/search-panel.tsx @@ -0,0 +1,192 @@ +import type { SearchContentMatch, SearchContentResult } from "@coder-studio/core"; +import { useAtomValue } from "jotai"; +import type { FC, ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { Button } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { PanelHeader } from "../../../shared/components/panel-header"; + +interface SearchPanelProps { + workspaceId: string; +} + +function renderPreview(match: SearchContentMatch): ReactNode { + const start = Math.max(0, match.previewColumnStart - 1); + const end = Math.max(start, match.previewColumnEnd - 1); + + return ( + <> + {match.preview.slice(0, start)} + {match.preview.slice(start, end)} + {match.preview.slice(end)} + + ); +} + +export const SearchPanel: FC = ({ workspaceId }) => { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId); + const inputRef = useRef(null); + const dispatchRef = useRef(dispatch); + const [query, setQuery] = useState(""); + const [retryNonce, setRetryNonce] = useState(0); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + dispatchRef.current = dispatch; + }, [dispatch]); + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setResults(null); + setLoading(false); + setError(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(false); + + const timeout = window.setTimeout(() => { + void dispatchRef + .current("file.searchContent", { + workspaceId, + query: trimmed, + maxFiles: 50, + maxMatchesPerFile: 20, + }) + .then((result) => { + if (cancelled) { + return; + } + + if (!result.ok || !result.data) { + setResults(null); + setError(true); + return; + } + + setResults(result.data); + }) + .catch(() => { + if (!cancelled) { + setResults(null); + setError(true); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [query, retryNonce, workspaceId]); + + return ( +
+ + +
+ setQuery(event.target.value)} + placeholder={t("workspace.search.placeholder")} + /> + +
+ {loading + ? t("common.loading") + : query.trim() + ? t("workspace.search.results_count", { + count: results?.totalMatchCount ?? 0, + files: results?.files.length ?? 0, + }) + : t("workspace.search.empty")} +
+ + {results && (results.hasMoreFiles || results.truncatedMatchFileCount > 0) ? ( +
+ {t("workspace.search.truncated")} +
+ ) : null} +
+ +
+ {error ? ( +
+

{t("workspace.search.failed")}

+ +
+ ) : !query.trim() ? ( +

{t("workspace.search.empty")}

+ ) : loading ? ( +

{t("common.loading")}

+ ) : !results || results.files.length === 0 ? ( +

{t("workspace.search.no_results")}

+ ) : ( + results.files.map((file) => ( +
+
+ {file.name} + {file.path} + + {t("workspace.search.file_match_count", { + count: file.matchCount, + suffix: file.hasMoreMatches ? "+" : "", + })} + +
+ + {file.matches.map((match) => ( + + ))} +
+ )) + )} +
+
+ ); +}; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 69782b17..1b7b16f9 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -163,7 +163,14 @@ "workspace": "Workspace" }, "search": { - "empty": "Search will appear here soon." + "empty": "Type to search across file contents.", + "placeholder": "Search across file contents", + "results_count": "{count} results in {files} files", + "no_results": "No content matches found.", + "truncated": "Results limited to keep search responsive.", + "file_match_count": "{count}{suffix} matches", + "failed": "Search failed. Try again.", + "retry": "Retry" }, "launch": { "kicker": "START WORKSPACE", @@ -192,6 +199,15 @@ "missing_provider": "{provider} CLI not found" } }, + "quick_open": { + "title": "Go to File", + "placeholder": "Type a filename or path", + "empty": "Type to search by filename or path.", + "no_results": "No matching files found.", + "failed": "File search failed. Try again.", + "command_label": "Go to File...", + "command_description": "Quickly open a file in the current workspace" + }, "mobile": { "dock": { "aria_label": "Mobile dock", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 48478210..26e304f7 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -163,7 +163,14 @@ "workspace": "工作区" }, "search": { - "empty": "搜索功能即将显示在这里。" + "empty": "输入关键词以搜索文件内容。", + "placeholder": "搜索文件内容", + "results_count": "在 {files} 个文件中找到 {count} 条结果", + "no_results": "未找到内容匹配。", + "truncated": "结果已截断,以保持搜索响应速度。", + "file_match_count": "{count}{suffix} 条匹配", + "failed": "搜索失败,请重试。", + "retry": "重试" }, "launch": { "kicker": "启动工作区", @@ -192,6 +199,15 @@ "missing_provider": "未找到 {provider} CLI" } }, + "quick_open": { + "title": "跳转到文件", + "placeholder": "输入文件名或路径", + "empty": "输入内容以按文件名或路径搜索。", + "no_results": "未找到匹配文件。", + "failed": "文件搜索失败,请重试。", + "command_label": "跳转到文件...", + "command_description": "在当前工作区中快速打开文件" + }, "mobile": { "dock": { "aria_label": "移动底栏", diff --git a/packages/web/src/shells/desktop-shell.test.tsx b/packages/web/src/shells/desktop-shell.test.tsx index 8deebc40..9a2d79e6 100644 --- a/packages/web/src/shells/desktop-shell.test.tsx +++ b/packages/web/src/shells/desktop-shell.test.tsx @@ -33,6 +33,10 @@ vi.mock("../features/command-palette", () => ({ CommandPalette: () => null, })); +vi.mock("../features/quick-open", () => ({ + QuickOpen: () =>
QuickOpen
, +})); + vi.mock("../features/workspace/views/shared/branch-quick-pick", () => ({ BranchQuickPick: () => null, })); @@ -206,6 +210,36 @@ describe("DesktopShell auth gating", () => { expect(screen.getByText("WorkspacePage")).toBeInTheDocument(); }); + it("mounts QuickOpen beside CommandPalette on desktop", () => { + window.history.replaceState({}, "", "/workspace"); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + store.set(workspacesAtom, { + "ws-1": { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(activeWorkspaceIdAtom, "ws-1"); + store.set(workspacesLoadStateAtom, "ready"); + + renderShell(store); + + expect(screen.getByText("QuickOpen")).toBeInTheDocument(); + }); + it("shows the shared workspace gate on desktop while /workspace is unresolved", () => { window.history.replaceState({}, "", "/workspace"); diff --git a/packages/web/src/shells/desktop-shell.tsx b/packages/web/src/shells/desktop-shell.tsx index effa35ca..b4f5332d 100644 --- a/packages/web/src/shells/desktop-shell.tsx +++ b/packages/web/src/shells/desktop-shell.tsx @@ -15,6 +15,7 @@ import { CommandPalette } from "../features/command-palette"; import { DiagnosticsPage } from "../features/diagnostics"; import { NotFoundPage } from "../features/not-found"; import { ToastContainer } from "../features/notifications"; +import { QuickOpen } from "../features/quick-open"; import { SettingsPage } from "../features/settings"; import { WelcomePage } from "../features/welcome"; import { WorkspaceDesktopView } from "../features/workspace/views/desktop/workspace-desktop-view"; @@ -85,6 +86,7 @@ export function DesktopShell() { )} +
diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 81ef9842..8744b052 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -12430,7 +12430,7 @@ textarea.input { .workspace-open-editors { display: flex; flex-direction: column; - gap: 2px; + gap: var(--gap-micro); } .workspace-open-editors__item { diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index c774e503..d88ea024 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -893,9 +893,10 @@ describe("components.css theme-sensitive surfaces", () => { const statusBar = getLastRuleBlock(".workspace-status-bar"); const agentPanes = getLastRuleBlock(".workspace-main-stage > .agent-panes"); const bottomPanel = getLastRuleBlock(".workspace-bottom-panel"); - const sidebarTabs = getLastRuleBlock(".workspace-sidebar-panel__tabs"); - const sidebarTab = getLastRuleBlock(".workspace-sidebar-panel__tab"); - const sidebarTabActiveAfter = getLastRuleBlock(".workspace-sidebar-panel__tab.active::after"); + const activityBar = getLastRuleBlock(".workspace-activity-bar"); + const activityBarButton = getLastRuleBlock(".workspace-activity-bar__button"); + const activityBarButtonHover = getLastRuleBlock(".workspace-activity-bar__button:hover"); + const activityBarButtonActive = getLastRuleBlock(".workspace-activity-bar__button--active"); const sidebarActions = getLastRuleBlock(".workspace-sidebar-panel__actions"); const verticalDividerRules = getRuleBlocksFrom(stylesheet, ".split-divider-v").join("\n"); const horizontalDividerRules = getRuleBlocksFrom(stylesheet, ".split-divider-h").join("\n"); @@ -989,9 +990,16 @@ describe("components.css theme-sensitive surfaces", () => { ); expect(resolvingStrongLine).toContain("border: 1px solid var(--state-info-border)"); expect(resolvingStrongLine).toContain("background: var(--state-info-bg)"); - expect(sidebarTabs).toContain("gap: var(--gap-default)"); - expect(sidebarTab).toContain("gap: var(--gap-control)"); - expect(sidebarTabActiveAfter).toContain("border-radius: var(--radius-pill)"); + expect(activityBar).toContain("border-right: 1px solid var(--border)"); + expect(activityBar).toContain( + "background: color-mix(in srgb, var(--bg-panel) 88%, var(--bg-page))" + ); + expect(activityBarButton).toContain("border-radius: var(--radius-lg)"); + expect(activityBarButton).toContain("background: transparent"); + expect(activityBarButtonHover).toContain("background: var(--bg-hover)"); + expect(activityBarButtonActive).toContain( + "background: color-mix(in srgb, var(--accent-blue) 14%, transparent)" + ); expect(sidebarActions).toContain("gap: var(--gap-control)"); expect(verticalDividerRules).toContain("width: 10px"); expect(verticalDividerRules).not.toContain("width: 8px"); From 21d0453a925d63e59d23b51d2cb96951a31566d4 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:15:40 +0800 Subject: [PATCH 03/41] Route settings activation errors to session gate --- .../settings/components/provider-settings.tsx | 9 ++-- .../components/settings-page.test.tsx | 19 ++++++++ .../settings/components/settings-page.tsx | 45 ++++++++++++++----- .../components/use-session-gate-dispatch.ts | 30 +++++++++++++ 4 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/features/settings/components/use-session-gate-dispatch.ts diff --git a/packages/web/src/features/settings/components/provider-settings.tsx b/packages/web/src/features/settings/components/provider-settings.tsx index 165fea3f..9b8f3165 100644 --- a/packages/web/src/features/settings/components/provider-settings.tsx +++ b/packages/web/src/features/settings/components/provider-settings.tsx @@ -2,11 +2,12 @@ import type { ProviderRuntimeStatusEntry, ProviderRuntimeStatusResponse } from " import { useAtomValue } from "jotai"; import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { connectionStatusAtom, dispatchCommandAtom } from "../../../atoms/connection"; +import { connectionStatusAtom } from "../../../atoms/connection"; import { Button, Notice, SegmentedControl, Textarea } from "../../../components/ui"; import { useTranslation } from "../../../lib/i18n"; import { buildDiagnosticsPath } from "../../diagnostics"; import { ConfigEditor, type ConfigType } from "./config-editor"; +import { useSessionGateDispatch } from "./use-session-gate-dispatch"; export interface ProviderInfo { id: "claude" | "codex"; @@ -48,7 +49,7 @@ export function ProviderSettings({ }: ProviderSettingsProps) { const t = useTranslation(); const navigate = useNavigate(); - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const connectionStatus = useAtomValue(connectionStatusAtom); const commandPreviewTitle = t("settings.provider.command_preview_title"); const commandPreviewHint = t("settings.provider.command_preview_hint"); @@ -170,7 +171,7 @@ export function ProviderSettings({ const loadRuntimeStatus = async () => { const result = await dispatch("provider.runtimeStatus", {}); - if (cancelled || !result.ok || !result.data) { + if (cancelled || result === null || !result.ok || !result.data) { return; } const providersData = result.data.providers ?? {}; @@ -212,7 +213,7 @@ export function ProviderSettings({ config: { additionalArgs }, }); - if (cancelled) { + if (cancelled || result === null) { return; } diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index 20465170..9e524a47 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -213,6 +213,25 @@ describe("SettingsPage", () => { }); }); + it("redirects to session gate instead of showing an inline load error when activation is required", async () => { + const sendCommand = vi.fn().mockRejectedValue( + new CommandResultError({ + code: "activation_required", + message: "This tab is no longer the active session", + }) + ); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + + await waitFor(() => { + expect(routerMocks.navigate).toHaveBeenCalledWith("/session-gate", { replace: true }); + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("This tab is no longer the active session")).not.toBeInTheDocument(); + }); + it("renders the footer version from server metadata", () => { const store = createConnectedStore(vi.fn().mockResolvedValue({})); store.set(serverInfoAtom, { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 6d28a30e..663e9dc5 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -30,11 +30,7 @@ import { Check, ChevronRight } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { localeAtom, themeAtom } from "../../../atoms/app-ui"; -import { - connectionStatusAtom, - dispatchCommandAtom, - serverInfoAtom, -} from "../../../atoms/connection"; +import { connectionStatusAtom, serverInfoAtom } from "../../../atoms/connection"; import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; import { Button, Input, Notice, Pill, Select, Switch, ThemedIcon } from "../../../components/ui"; import { useViewport } from "../../../hooks/use-viewport"; @@ -64,6 +60,7 @@ import { type SettingsSection, } from "./settings-sections"; import { ShortcutsSettings } from "./shortcuts-settings"; +import { useSessionGateDispatch } from "./use-session-gate-dispatch"; type NotificationCapabilityStatus = "available" | "limited" | "unsupported"; type NotificationPermissionState = NotificationPermission | "unavailable"; @@ -212,7 +209,7 @@ export function SettingsPage() { const navigate = useNavigate(); const viewport = useViewport(); const isMobile = viewport === "mobile"; - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const connectionStatus = useAtomValue(connectionStatusAtom); const serverInfo = useAtomValue(serverInfoAtom); const resolvedActiveWorkspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); @@ -354,6 +351,10 @@ export function SettingsPage() { ...updateSelectionVersionRef.current, }; const result = await dispatch>("settings.get", {}); + if (result === null) { + return; + } + if (!result.ok || !result.data) { if (!cancelled) { setSettingsLoadError(result.error?.message ?? settingsLoadFailedUnknownRef.current); @@ -564,12 +565,12 @@ export function SettingsPage() { }, }, }); - if (!persistResult.ok) { + if (persistResult === null || !persistResult.ok) { return; } const runtimeResult = await dispatch("lsp.setMode", { mode: nextMode }); - if (!runtimeResult.ok) { + if (runtimeResult === null || !runtimeResult.ok) { return; } @@ -592,6 +593,9 @@ export function SettingsPage() { updateSelectionVersionRef.current.autoCheckEnabled += 1; setUpdateAutoCheckEnabled(value); const result = await saveUpdateSettings({ autoCheckEnabled: value }); + if (result === null) { + return; + } if (!result.ok) { setUpdateAutoCheckEnabled((current) => !value); } @@ -605,6 +609,9 @@ export function SettingsPage() { updateSelectionVersionRef.current.checkIntervalSec += 1; setUpdateCheckIntervalSec(value); const result = await saveUpdateSettings({ checkIntervalSec: value }); + if (result === null) { + return; + } if (!result.ok) { setUpdateCheckIntervalSec(previous); } @@ -977,7 +984,7 @@ function GeneralSettings({ const lspRuntimeModeDescId = useId(); const copyOnSelectLabelId = useId(); const copyOnSelectDescId = useId(); - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const setNotificationPreferences = useSetAtom(notificationPreferencesAtom); const [notificationPermission, setNotificationPermission] = useState("unavailable"); @@ -1072,6 +1079,10 @@ function GeneralSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setSupervisorTimeoutDraft(String(supervisorEvaluationTimeoutSec)); setSupervisorTimeoutError(result.error?.message || t("settings.config_files.save_failed")); @@ -1107,6 +1118,10 @@ function GeneralSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setSupervisorRetryMaxCountDraft(String(supervisorRetryMaxCount)); setSupervisorRetryMaxCountError( @@ -1144,6 +1159,10 @@ function GeneralSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setSupervisorRetryDelayDraft(String(supervisorRetryDelaySec)); setSupervisorRetryDelayError(result.error?.message || t("settings.config_files.save_failed")); @@ -1605,7 +1624,7 @@ function AppearanceSettings({ const desktopTerminalFontSizeDescId = useId(); const mobileTerminalFontSizeLabelId = useId(); const mobileTerminalFontSizeDescId = useId(); - const dispatch = useAtomValue(dispatchCommandAtom); + const dispatch = useSessionGateDispatch(); const currentThemeId = resolveStoredThemeId(theme); const themeOptions = THEMES.map((registeredTheme) => ({ value: registeredTheme.id, @@ -1631,7 +1650,7 @@ function AppearanceSettings({ }); const saveSettings = async (settings: Record) => { - await dispatch("settings.update", { settings }); + return await dispatch("settings.update", { settings }); }; useEffect(() => { @@ -1704,6 +1723,10 @@ function AppearanceSettings({ }, }); + if (result === null) { + return; + } + if (!result.ok) { setDraft(String(currentValue)); setError(result.error?.message || t("settings.config_files.save_failed")); diff --git a/packages/web/src/features/settings/components/use-session-gate-dispatch.ts b/packages/web/src/features/settings/components/use-session-gate-dispatch.ts new file mode 100644 index 00000000..6237d115 --- /dev/null +++ b/packages/web/src/features/settings/components/use-session-gate-dispatch.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + type CommandResult, + type DispatchCommandOptions, + dispatchCommandAtom, +} from "../../../atoms/connection"; + +export function useSessionGateDispatch() { + const dispatch = useAtomValue(dispatchCommandAtom); + const navigate = useNavigate(); + + return useCallback( + async function dispatchWithSessionGate( + op: string, + args: unknown, + options?: DispatchCommandOptions + ): Promise | null> { + const result = await dispatch(op, args, options); + if (!result.ok && result.error?.code === "activation_required") { + navigate("/session-gate", { replace: true }); + return null; + } + + return result; + }, + [dispatch, navigate] + ); +} From 6927c7c6f9980c8f425e8f9a5e835537b5b25c39 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:27:56 +0800 Subject: [PATCH 04/41] Fix update restart handoff process tree --- packages/cli/src/update-worker.test.ts | 65 ++++++++-- packages/cli/src/update-worker.ts | 161 ++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/update-worker.test.ts b/packages/cli/src/update-worker.test.ts index 253bcd1c..3ae72169 100644 --- a/packages/cli/src/update-worker.test.ts +++ b/packages/cli/src/update-worker.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { runUpdateWorker } from "./update-worker.js"; +import { runRestartHandoff, runUpdateWorker } from "./update-worker.js"; describe("update-worker", () => { const tempDirs: string[] = []; @@ -29,13 +29,16 @@ describe("update-worker", () => { }; } - it("writes restarting state after install success and restart handoff", async () => { + it("writes restarting state and spawns a detached restart handoff after install success", async () => { const env = createEnv(); const runCommand = vi.fn(async () => {}); + const spawnDetachedProcess = vi.fn(async () => {}); await runUpdateWorker(env, { runCommand, now: () => 1000, + processId: 4242, + spawnDetachedProcess, }); const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { updateStatus: string }; @@ -46,11 +49,13 @@ describe("update-worker", () => { ["install", "-g", "@spencer-kit/coder-studio@0.5.0"], expect.any(Object) ); - expect(runCommand).toHaveBeenNthCalledWith( - 2, - "coder-studio", - ["serve", "--restart"], - expect.any(Object) + expect(spawnDetachedProcess).toHaveBeenCalledWith( + process.execPath, + expect.any(Array), + expect.objectContaining({ + CODER_STUDIO_UPDATE_WORKER_MODE: "restart-handoff", + CODER_STUDIO_UPDATE_PARENT_PID: "4242", + }) ); }); @@ -77,14 +82,14 @@ describe("update-worker", () => { it("marks restart failures with manual restart guidance", async () => { const env = createEnv(); - const runCommand = vi - .fn() - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("pm2 restart failed")); + const runCommand = vi.fn().mockRejectedValueOnce(new Error("pm2 restart failed")); + const waitForProcessExit = vi.fn(async () => {}); - await runUpdateWorker(env, { + await runRestartHandoff(env, { runCommand, now: () => 1000, + waitForProcessExit, + restartParentPid: 999, }); const state = JSON.parse(readFileSync(env.stateFilePath, "utf-8")) as { @@ -95,11 +100,13 @@ describe("update-worker", () => { expect(state.updateStatus).toBe("failed"); expect(state.manualCommand).toBe("coder-studio serve --restart"); expect(state.errorSummary).toContain("restart failed"); + expect(waitForProcessExit).toHaveBeenCalledWith(999); }); it("sanitizes pm2 and runtime override env before invoking install and restart commands", async () => { const env = createEnv(); const runCommand = vi.fn(async () => {}); + const spawnDetachedProcess = vi.fn(async () => {}); const originalEnv = { PM2_HOME: process.env.PM2_HOME, PM2_PROGRAMMATIC: process.env.PM2_PROGRAMMATIC, @@ -130,6 +137,8 @@ describe("update-worker", () => { await runUpdateWorker(env, { runCommand, now: () => 1000, + processId: 4242, + spawnDetachedProcess, }); } finally { for (const [key, value] of Object.entries(originalEnv)) { @@ -155,5 +164,37 @@ describe("update-worker", () => { expect(options.env?.CODER_STUDIO_UPDATE_STATE_PATH).toBeUndefined(); expect(options.env?.pm_id).toBeUndefined(); } + + const handoffEnv = spawnDetachedProcess.mock.calls[0]?.[2] as NodeJS.ProcessEnv | undefined; + expect(handoffEnv?.PM2_HOME).toBe("/tmp/custom-pm2-home"); + expect(handoffEnv?.PM2_PROGRAMMATIC).toBeUndefined(); + expect(handoffEnv?.PM2_JSON_PROCESSING).toBeUndefined(); + expect(handoffEnv?.PM2_INTERACTOR_PROCESSING).toBeUndefined(); + expect(handoffEnv?.NODE_APP_INSTANCE).toBeUndefined(); + expect(handoffEnv?.NODE_CHANNEL_FD).toBeUndefined(); + expect(handoffEnv?.NODE_CHANNEL_SERIALIZATION_MODE).toBeUndefined(); + expect(handoffEnv?.CODER_STUDIO_RUNTIME_JSON_PATH).toBeUndefined(); + expect(handoffEnv?.CODER_STUDIO_SESSION_ID).toBeUndefined(); + expect(handoffEnv?.pm_id).toBeUndefined(); + }); + + it("waits for the install worker to exit before running the restart command", async () => { + const env = createEnv(); + const waitForProcessExit = vi.fn(async () => {}); + const runCommand = vi.fn(async () => {}); + + await runRestartHandoff(env, { + runCommand, + now: () => 1000, + waitForProcessExit, + restartParentPid: 777, + }); + + expect(waitForProcessExit).toHaveBeenCalledWith(777); + expect(runCommand).toHaveBeenCalledWith( + "coder-studio", + ["serve", "--restart"], + expect.any(Object) + ); }); }); diff --git a/packages/cli/src/update-worker.ts b/packages/cli/src/update-worker.ts index a83f91af..c552fa68 100644 --- a/packages/cli/src/update-worker.ts +++ b/packages/cli/src/update-worker.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; interface UpdateStateSnapshot { version: 1; @@ -37,6 +38,13 @@ interface WorkerEnv { installArgsPrefix: string[]; } +type WorkerMode = "install" | "restart-handoff"; + +const RESTART_HANDOFF_MODE: WorkerMode = "restart-handoff"; +const DEFAULT_MODE: WorkerMode = "install"; +const RESTART_HANDOFF_WAIT_MS = 5_000; +const WORKER_ENTRY_PATH = fileURLToPath(import.meta.url); + async function writeState(filePath: string, value: UpdateStateSnapshot): Promise { await mkdir(dirname(filePath), { recursive: true }); await import("node:fs/promises").then(({ writeFile }) => @@ -44,6 +52,16 @@ async function writeState(filePath: string, value: UpdateStateSnapshot): Promise ); } +function closeLogStream(stream: NodeJS.WritableStream): Promise { + return new Promise((resolve, reject) => { + stream.once("error", reject); + stream.end(() => { + stream.off("error", reject); + resolve(); + }); + }); +} + function parseJsonArray(value: string | undefined, fallback: string[]): string[] { if (!value) { return fallback; @@ -97,6 +115,22 @@ function buildManualCommand(input: WorkerEnv): string { ].join("\n"); } +function readWorkerMode(env = process.env): WorkerMode { + return env.CODER_STUDIO_UPDATE_WORKER_MODE === RESTART_HANDOFF_MODE + ? RESTART_HANDOFF_MODE + : DEFAULT_MODE; +} + +function readRestartParentPid(env = process.env): number | null { + const raw = env.CODER_STUDIO_UPDATE_PARENT_PID; + if (!raw) { + return null; + } + + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + const INTERNAL_ENV_KEYS = new Set([ "CODER_STUDIO_RUNTIME_JSON_PATH", "CODER_STUDIO_SESSION_ID", @@ -125,6 +159,71 @@ function buildChildProcessEnv(env = process.env): NodeJS.ProcessEnv { return nextEnv; } +function buildWorkerEnv(input: WorkerEnv): NodeJS.ProcessEnv { + return { + CODER_STUDIO_UPDATE_STATE_PATH: input.stateFilePath, + CODER_STUDIO_UPDATE_LOG_PATH: input.logFilePath, + CODER_STUDIO_UPDATE_PACKAGE_NAME: input.packageName, + CODER_STUDIO_UPDATE_TARGET_VERSION: input.targetVersion, + CODER_STUDIO_UPDATE_CLI_COMMAND: input.cliCommand, + CODER_STUDIO_UPDATE_CURRENT_VERSION: input.currentVersion, + CODER_STUDIO_UPDATE_NPM_COMMAND: input.npmCommand, + CODER_STUDIO_UPDATE_RESTART_ARGS: JSON.stringify(input.restartArgs), + CODER_STUDIO_UPDATE_INSTALL_ARGS_PREFIX: JSON.stringify(input.installArgsPrefix), + }; +} + +function spawnDetachedProcess( + command: string, + args: string[], + env: NodeJS.ProcessEnv +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + env, + }); + + child.on("error", reject); + child.unref(); + resolve(); + }); +} + +const isMissingProcessError = (error: unknown): boolean => + Boolean( + error && + typeof error === "object" && + "code" in error && + (error as NodeJS.ErrnoException).code === "ESRCH" + ); + +async function waitForProcessExit(pid: number, waitMs = RESTART_HANDOFF_WAIT_MS): Promise { + const deadline = Date.now() + waitMs; + + while (Date.now() <= deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (isMissingProcessError(error)) { + return; + } + + throw error; + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + + await new Promise((resolve) => { + setTimeout(resolve, Math.min(100, remainingMs)); + }); + } +} + function runCommand( command: string, args: string[], @@ -163,6 +262,8 @@ export async function runUpdateWorker( deps?: { runCommand?: typeof runCommand; now?: () => number; + processId?: number; + spawnDetachedProcess?: typeof spawnDetachedProcess; } ): Promise { const now = deps?.now ?? Date.now; @@ -170,6 +271,8 @@ export async function runUpdateWorker( const logStream = createWriteStream(input.logFilePath, { flags: "a" }); const execute = deps?.runCommand ?? runCommand; const childEnv = buildChildProcessEnv(process.env); + const processId = deps?.processId ?? process.pid; + const spawnRestartHandoff = deps?.spawnDetachedProcess ?? spawnDetachedProcess; try { await execute( @@ -196,7 +299,7 @@ export async function runUpdateWorker( manualCommand: permissionRelated ? buildManualCommand(input) : null, errorSummary: message, }); - logStream.end(); + await closeLogStream(logStream); return; } @@ -216,6 +319,55 @@ export async function runUpdateWorker( }); try { + await spawnRestartHandoff(process.execPath, [WORKER_ENTRY_PATH], { + ...childEnv, + ...buildWorkerEnv(input), + CODER_STUDIO_UPDATE_WORKER_MODE: RESTART_HANDOFF_MODE, + CODER_STUDIO_UPDATE_PARENT_PID: String(processId), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await writeState(input.stateFilePath, { + version: 1, + currentVersion: input.currentVersion, + latestVersion: input.targetVersion, + availability: "update_available", + updateStatus: "failed", + lastCheckedAt: now(), + targetVersion: input.targetVersion, + startedAt: now(), + finishedAt: now(), + requiresManualStep: true, + manualCommand: `${input.cliCommand} ${input.restartArgs.join(" ")}`, + errorSummary: `new version installed but service restart failed: ${message}`, + }); + } finally { + await closeLogStream(logStream); + } +} + +export async function runRestartHandoff( + input = readEnv(), + deps?: { + runCommand?: typeof runCommand; + now?: () => number; + waitForProcessExit?: typeof waitForProcessExit; + restartParentPid?: number | null; + } +): Promise { + const now = deps?.now ?? Date.now; + await mkdir(dirname(input.logFilePath), { recursive: true }); + const logStream = createWriteStream(input.logFilePath, { flags: "a" }); + const execute = deps?.runCommand ?? runCommand; + const waitForParentExit = deps?.waitForProcessExit ?? waitForProcessExit; + const childEnv = buildChildProcessEnv(process.env); + const restartParentPid = deps?.restartParentPid ?? readRestartParentPid(process.env); + + try { + if (restartParentPid !== null) { + await waitForParentExit(restartParentPid); + } + await execute(input.cliCommand, input.restartArgs, { logStream, env: childEnv }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -234,12 +386,15 @@ export async function runUpdateWorker( errorSummary: `new version installed but service restart failed: ${message}`, }); } finally { - logStream.end(); + await closeLogStream(logStream); } } if (process.env.CODER_STUDIO_UPDATE_STATE_PATH) { - void runUpdateWorker().catch((error) => { + const run = + readWorkerMode(process.env) === RESTART_HANDOFF_MODE ? runRestartHandoff : runUpdateWorker; + + void run().catch((error) => { console.error("[update-worker]", error); process.exitCode = 1; }); From eed5778d9e377f2568e337519a90df98b599782a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:43:50 +0800 Subject: [PATCH 05/41] docs(workspace): add search and quick open visual refresh spec --- ...search-quick-open-visual-refresh-design.md | 357 ++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md diff --git a/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md b/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md new file mode 100644 index 00000000..5dfce0cd --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md @@ -0,0 +1,357 @@ +# Workspace Search And Quick Open Visual Refresh Design + +> Status: Draft +> Date: 2026-05-23 +> Scope: `packages/web/src/features/workspace/views/shared/search-panel.tsx`, `packages/web/src/features/quick-open/components/quick-open.tsx`, corresponding `packages/web/src/styles/components.css` selectors and tests + +## Goal + +Refine the new desktop `Search` sidebar and `Quick Open` overlay so they read and behave much closer to VS Code. + +This design does not introduce new search domains. It improves the existing file-content search sidebar and file-only quick open surface so they feel like editor tooling instead of generic app panels. + +The target outcome: + +- `Search` becomes easier to scan in narrow sidebar width +- result groups become understandable at a glance +- repeated file/path noise is removed from every match row +- `Quick Open` feels like a file jump surface rather than a general search modal + +## Relationship To Existing Spec + +This document refines the UI treatment from: + +- [2026-05-23-workspace-sidebar-search-quick-open-design.md](/home/spencer/workspace/coder-studio/docs/superpowers/specs/2026-05-23-workspace-sidebar-search-quick-open-design.md) + +That earlier spec defines the feature set and high-level information architecture. + +This document only covers the visual hierarchy, result grouping behavior, and interaction details for the desktop `Search` sidebar and `Quick Open`. + +## In Scope + +- desktop `Search` sidebar visual hierarchy +- grouped search results by file with per-file expand and collapse +- default expand behavior for new search results +- compact result row treatment for content matches +- `Quick Open` file-only visual treatment +- `Quick Open` result row structure and active state +- copy and spacing updates needed to support the new hierarchy + +## Out Of Scope + +- search and replace +- regex, case sensitivity, or whole-word toggles +- command, symbol, or recent-item results inside `Quick Open` +- mobile `FileTreePanel` search changes +- command palette redesign +- backend ranking changes +- new search commands or API changes + +## Problem + +The current `Search` sidebar is functionally correct but visually weak: + +- the summary, file identity, path, and match rows compete at the same weight +- file path text runs directly into the file title instead of forming a readable group header +- every match row feels detached from its file +- the sidebar lacks the compact, inspectable rhythm users expect from editor search + +The current `Quick Open` works as a file opener but still reads more like a generic search list: + +- result rows are too flat +- file identity and path hierarchy are weak +- the overlay does not yet feel like a focused file jump tool + +## Decision Summary + +Adopt a strict VS Code-leaning presentation for both surfaces. + +### Search Sidebar + +- keep the single search input and summary line +- render results as collapsible file groups +- default all file groups to expanded after each successful query +- let users collapse or expand each file independently +- move file identity to the group header and keep match rows compact + +### Quick Open + +- keep file-only results +- render results in a two-line structure +- show file name as the primary line +- show the relative path as the secondary, de-emphasized line +- keep keyboard behavior unchanged + +This is the recommended design because it addresses the actual usability problem, not just surface styling. + +## Search Sidebar Design + +## Overall Tone + +The sidebar should feel like editor chrome: + +- compact +- low-radius +- low-shadow +- text-first +- dense enough for scanning + +Avoid card-like grouping, large empty blocks, or decorative emphasis. + +## Search Header Area + +The top of the panel keeps the existing title and single search field. + +Visual treatment: + +- the search input should be narrower in height and closer to VS Code field chrome +- corners should be restrained rather than pill-like +- focus state should read as an editor input, not a marketing form field + +Below the input, show a compact summary line: + +- empty query: instructional text +- loading: loading text +- populated query: `X results in Y files` + +If the backend truncates results, show a short secondary note directly below the summary. + +## Search Result Grouping + +Search results are rendered as file groups. + +Each group consists of: + +- a clickable group header +- a collapsible list of match rows + +### Group Header Content + +Each file header shows: + +- chevron for expand or collapse +- file name as primary text +- relative path as secondary text +- match count right-aligned + +The file path should be visually subordinate and never run into the title as unstructured text. + +### Default Expansion + +After each successful search query: + +- all returned file groups start expanded + +This matches the expected scan-first workflow and is closest to VS Code. + +### Collapse Behavior + +Users can collapse or expand a file group by clicking the group header. + +Collapse state is local to the current result set and should reset when a new successful query returns. + +This means: + +- typing a new search term restores all groups to expanded +- retrying a failed query also restores all groups to expanded on success + +Persisting collapse state across queries is out of scope because it creates confusing carry-over between different result sets. + +## Search Match Rows + +Each match row should become a compact editor-style result line. + +Row structure: + +- fixed-width line number column +- preview text column + +Behavior: + +- clicking a match opens the file at that location +- the sidebar remains open after navigation + +Visual rules: + +- line numbers are low emphasis and right-aligned +- preview text is primary +- highlighted match text uses the existing search highlight treatment, but should not overpower the row +- hover and active states are single-layer backgrounds, not cards + +Rows must not repeat file name or path, because that context already exists in the group header. + +## Search States + +All non-result states should stay compact and tool-like. + +### Idle + +Show a short prompt such as: + +- `Type to search across file contents.` + +### Loading + +Show a lightweight loading line in the results area. + +### No Results + +Show a short no-results message without large empty-state chrome. + +### Error + +Keep the retry affordance, but reduce visual bulk: + +- short error text +- compact retry button below or beside it + +## Quick Open Design + +## Behavioral Scope + +`Quick Open` remains file-only in this iteration. + +It does not include: + +- commands +- symbols +- recents +- workspace actions + +This keeps the overlay aligned with its current data model and prevents the UI refresh from turning into a mixed-result redesign. + +## Overlay Tone + +The overlay should feel like a focused file switcher: + +- restrained chrome +- compact input bar +- dense result list +- clear active-row highlight + +It should visually move closer to VS Code and away from a generic modal sheet. + +## Result Row Structure + +Each result row becomes a two-line item: + +- primary line: file name +- secondary line: relative path + +Hierarchy rules: + +- file name carries most of the contrast +- path is smaller or lower-contrast +- the active row uses a single background fill + +This keeps rows readable when many files share similar names across directories. + +## Keyboard And Pointer Behavior + +Keep the current interaction contract: + +- `Ctrl/Cmd+P` opens +- up and down arrows move active selection +- `Enter` opens the active file +- `Escape` closes +- hover updates the active row +- click opens and closes + +No command-prefix parsing is added in this pass. + +## Component And State Changes + +## Search Sidebar + +The `SearchPanel` client state should add a per-query expand map keyed by file path. + +Required behavior: + +- initialize all returned paths to expanded on successful search +- toggle individual paths from the group header +- keep match rows mounted only when the group is expanded + +The backend payload already groups matches by file, so no server contract changes are required. + +## Quick Open + +`QuickOpen` keeps its current fetch model and selection model. + +The change is limited to: + +- row markup hierarchy +- spacing +- active styling +- empty and loading presentation polish + +## Accessibility + +Search group headers should be keyboard reachable controls with clear expanded state semantics. + +Required expectations: + +- collapsed and expanded state must be conveyed to assistive tech +- hit targets should remain usable in narrow sidebar widths +- active quick-open item contrast must remain clear +- highlighted search text must not become unreadable in dark theme + +## Testing + +## Search Sidebar + +Update and extend tests to cover: + +- grouped file rendering still works +- each file group starts expanded after results load +- clicking a group header collapses that file's matches +- clicking again re-expands the matches +- a new successful query resets returned groups to expanded +- navigation from a match row still opens the correct location + +Style-oriented tests should verify: + +- group header structure selectors exist +- line number and preview columns keep the compact hierarchy +- summary and truncation note remain in the expected order + +## Quick Open + +Update tests to cover: + +- two-line result structure +- active row styling hook remains present +- keyboard navigation and enter-to-open behavior stay unchanged +- no mixed result types are introduced + +## Risks And Mitigations + +### Risk: Search Sidebar Gets Too Dense + +Mitigation: + +- keep line height readable +- use contrast hierarchy instead of shrinking everything +- keep only one secondary text line in group headers + +### Risk: Collapse State Feels Unstable + +Mitigation: + +- define reset semantics clearly: every successful query returns to fully expanded + +### Risk: Quick Open Becomes Visually Inconsistent With Existing Shared Layers + +Mitigation: + +- preserve existing overlay sizing and focus management +- limit the change to row hierarchy and chrome polish + +## Acceptance Criteria + +- desktop `Search` results render as per-file groups with headers and collapsible match lists +- all file groups are expanded by default after each successful search +- file name, path, and match count are clearly separated in the file header +- match rows no longer repeat the file identity +- `Quick Open` remains file-only +- `Quick Open` rows display a primary file name line and a secondary path line +- both surfaces feel visually closer to VS Code than the current implementation From e1b06d81fad6ec832e3ef67775624acbeac3e51d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:44:41 +0800 Subject: [PATCH 06/41] docs: add agent pane keepalive design --- .../2026-05-23-agent-pane-keepalive-design.md | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md diff --git a/docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md b/docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md new file mode 100644 index 00000000..8ede383b --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-agent-pane-keepalive-design.md @@ -0,0 +1,468 @@ +# Agent Pane Keepalive Design + +> **Date:** 2026-05-23 +> **Status:** Draft +> **Scope:** `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`, `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`, `packages/web/src/features/agent-panes/*`, `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`, related desktop workspace CSS + +## 1. Goal + +Stop desktop editor switches from destroying agent terminal runtime state. + +The target outcome is: + +- switching the desktop main area from agent view to editor view does not unmount `AgentPanes` +- switching back to the agent view reuses the original `xterm` instances instead of rebuilding them +- same-page editor switches do not trigger `terminal.replay` or `terminal.snapshot` +- terminal output, scrollback, cursor state, and websocket subscriptions remain continuous while the editor is in front +- existing server recovery remains the fallback for page refresh, reconnect, sequence gaps, and real cold starts + +## 2. Current Problem + +Today the desktop workspace main stage renders either the editor or the agent panes. + +That conditional rendering couples view mode to terminal runtime lifetime: + +- when `mainAreaMode === "editor"`, the desktop view unmounts `AgentPanes` +- unmounting `AgentPanes` unmounts `SessionCard` and `XtermHost` +- `XtermHost` cleanup disposes the `xterm` instance, removes terminal subscriptions, and drops component-local runtime state + +This causes two user-visible problems: + +- a simple editor switch behaves like a terminal cold start when the user comes back +- if the session or terminal changes state while the editor is in front, returning to the agent view can surface replay or closed-session UI that exists only because the terminal had to be rebuilt + +## 3. Root Cause + +The root cause is not terminal recovery itself. The root cause is that desktop view switching currently destroys the terminal host. + +The architecture treats: + +- "this layer is not visible right now" + +as if it were: + +- "this runtime is no longer needed" + +That is the wrong boundary for long-lived interactive terminals. + +View visibility and terminal runtime lifetime must be separated. + +## 4. Decision Summary + +Adopt a desktop-only keepalive model for agent panes. + +### 4.1 Main Decision + +- keep `AgentPanes` mounted at all times inside the desktop workspace main stage +- render the editor as a frontmost overlay layer when `mainAreaMode === "editor"` +- treat the agent layer as `covered`, not removed, while the editor is visible + +### 4.2 Terminal Decision + +- preserve existing `xterm` instances during desktop view switches +- add an `isVisible` signal to terminal hosts so they can downgrade interaction while covered +- do not introduce a new frontend terminal screen model or terminal snapshot cache in the first phase + +### 4.3 Recovery Decision + +- keep existing `terminal.replay` and `terminal.snapshot` flows for true recovery scenarios +- explicitly remove desktop editor switches from the set of events that imply terminal recovery + +This is the recommended design because it fixes the actual regression boundary with the smallest change set. It avoids introducing a second terminal state model while preserving existing server recovery guarantees. + +## 5. In Scope + +This design includes: + +- desktop workspace main-stage restructuring so `AgentPanes` remain mounted +- a covered/foreground visibility model for the desktop agent layer +- `XtermHost` visibility-aware interaction changes +- hydration priority changes for covered terminals +- overlay gating so covered terminals do not surface interactive dialogs behind the editor +- tests that prove editor switching no longer causes terminal remount or replay + +## 6. Out of Scope + +This design does not include: + +- mobile workspace behavior changes +- editor keepalive behavior +- persistent frontend terminal snapshots across page reloads +- a new standalone terminal runtime manager +- a custom frontend terminal screen model +- removing or redesigning server-side replay and snapshot recovery +- optimizing hidden terminal DOM cost beyond basic interaction suppression + +## 7. High-Level Architecture + +### 7.1 Desktop Main Stage + +`workspace-main-stage` becomes a layered stage rather than an either-or content slot. + +The stage contains: + +- an always-mounted agent layer +- an editor layer that mounts only when the editor is the active foreground surface + +The editor layer visually covers the agent layer without changing the agent layer's layout box. + +### 7.2 Agent Layer + +The agent layer owns: + +- `AgentPanes` +- `SessionCard` +- `XtermHost` +- the live `xterm` runtime and its subscriptions + +The layer remains mounted even when the editor is in front. + +### 7.3 Editor Layer + +The editor layer remains view-owned rather than runtime-owned in phase 1. + +It may still mount and unmount with `mainAreaMode`, because the regression being fixed is terminal destruction, not editor lifecycle. + +### 7.4 CSS Strategy + +The keepalive model depends on keeping the agent layer measurable. + +The first phase must not hide the agent layer with `display: none`. + +Instead: + +- the stage becomes a relative positioning container +- the editor layer uses absolute positioning with `inset: 0` +- the agent layer continues to occupy the full stage dimensions underneath + +This keeps terminal sizing stable and avoids zero-sized parent measurements. + +## 8. State Model + +### 8.1 Foreground Mode + +The existing `mainAreaMode` stays as: + +- `"agent"` +- `"editor"` + +Its meaning changes slightly: + +- it indicates which layer is in the foreground +- it does not determine whether the agent layer exists + +### 8.2 Derived Visibility + +Desktop agent visibility is derived, not independently stored: + +- `agentLayerCovered = mainAreaMode === "editor"` +- `terminalIsVisible = mainAreaMode === "agent"` + +The design does not require a new global atom for covered state in phase 1. + +### 8.3 Covered State Semantics + +When the editor is in front, the agent layer enters `covered` state. + +Covered terminals: + +- remain mounted +- continue receiving output +- continue updating scrollback and internal buffer state +- continue receiving terminal exit and session state changes + +Covered terminals must not: + +- accept stdin +- receive pointer interaction +- auto-focus +- render a blinking cursor +- promote themselves to focused hydration priority +- surface interactive replay or closed-session dialogs as frontmost UI + +### 8.4 Focus Rules + +When switching from agent view to editor view: + +- focus moves to the editor layer +- covered terminals immediately disable input + +When switching back to agent view: + +- terminals become interactive again +- the active terminal may refit +- phase 1 does not automatically return focus to the terminal + +That conservative focus rule avoids hidden-terminal keyboard capture and avoids introducing a new focus memory feature into the first release. + +## 9. Component Responsibilities + +### 9.1 `WorkspaceDesktopView` + +`WorkspaceDesktopView` becomes the composition point for keepalive. + +Responsibilities: + +- always render the agent layer +- mount the editor layer only when needed +- apply visibility and interaction classes to the agent layer +- mark the covered agent layer as hidden from accessibility and pointer interaction + +### 9.2 `useWorkspaceScreenModel` + +The screen model continues to compute `mainAreaMode`. + +Its responsibility remains product intent: + +- determine whether agent or editor should be in front + +It no longer implies that the other layer should be removed. + +### 9.3 `AgentPanes` and `SessionCard` + +The agent panes stack remains structurally the same. + +Phase 1 changes are limited to threading a visibility signal downward so terminal hosts can distinguish: + +- active and visible +- active but covered + +### 9.4 `XtermHost` + +`XtermHost` remains the owner of the live `xterm` instance. + +Phase 1 changes: + +- accept an `isVisible` input +- gate interaction behavior on `isVisible` +- keep mount lifetime independent from desktop main-area view changes + +### 9.5 CSS Layer Classes + +Desktop workspace CSS gains explicit layer classes for: + +- stage container +- agent layer +- covered agent layer +- editor overlay layer + +## 10. `XtermHost` Behavior Changes + +### 10.1 New Visibility Input + +`XtermHost` receives `isVisible: boolean`. + +This flag does not affect: + +- terminal identity +- terminal keys +- mount lifetime +- replay strategy + +It only affects foreground interaction behavior. + +### 10.2 Effective Interactivity + +Current interactivity is driven by terminal liveness and read-only state. + +Phase 1 adds visibility to that contract: + +- visible terminal: existing interactivity rules still apply +- covered terminal: input is always disabled even if the session is otherwise interactive + +Practical consequences: + +- `disableStdin = true` while covered +- `cursorBlink = false` while covered + +### 10.3 Hydration Priority + +Covered terminals must not compete with visible terminals for visible hydration tiers. + +Phase 1 rule: + +- covered terminals request or promote to `background` +- visible terminals continue using existing `focused`, `visible-active`, and `visible-other` tiers + +This preserves recovery and hydration fairness when the editor is in front. + +### 10.4 Fit on Return + +When `isVisible` transitions from `false` to `true`, the terminal schedules a refit. + +The refit is a display correction, not a recovery action. + +It exists to avoid stale row or column calculations after overlay transitions. + +### 10.5 Overlay Gating + +Interactive replay or closed-session overlays may still become internally relevant while a terminal is covered, but they must not become the foreground interactive surface. + +Phase 1 rule: + +- covered terminals may track degraded or closed state +- interactive overlay actions are only enabled when the terminal is visible + +## 11. Recovery Semantics + +### 11.1 Recovery Flows That Remain + +The following scenarios continue to use existing recovery logic: + +- real initial mount +- page refresh +- websocket reconnect +- terminal sequence gaps +- replay too old fallback +- snapshot rebuild fallback +- truly closed or unavailable terminals + +### 11.2 Recovery Flows Removed From View Switching + +The following transitions must no longer imply historical recovery: + +- desktop `agent -> editor` +- desktop `editor -> agent` + +After keepalive, those transitions are visibility updates only. + +### 11.3 Consequence + +Returning from the editor to the agent view must not: + +- create a new `xterm` instance +- request replay because of the view switch itself +- request snapshot because of the view switch itself +- surface a replay loading overlay because of the view switch itself + +If replay or snapshot occurs after this design lands, it must be explainable by a real recovery trigger rather than a foreground change. + +## 12. Product Behavior + +### 12.1 Switching to Editor + +When the user opens a file or diff preview: + +- the editor layer appears on top of the stage +- the agent layer remains mounted underneath +- terminal output continues to accumulate +- hidden terminals become non-interactive + +### 12.2 Switching Back to Agent + +When the editor closes and the user returns to the agent view: + +- the editor layer unmounts +- the agent layer becomes visible again +- the existing terminal instance is still present +- the terminal may refit +- the terminal does not cold-start + +### 12.3 Terminal Ends While Covered + +If a session or terminal ends while the editor is in front: + +- state updates continue flowing into the store +- the covered terminal may become ended or closed internally +- no hidden interactive dialog should take over the foreground + +When the user returns to the agent layer, the resulting ended or closed state is shown as a normal foreground pane state. + +## 13. Validation Strategy + +The primary validation target is lifecycle, not recovery correctness. + +### 13.1 Must-Prove Behaviors + +- switching to the editor does not unmount `AgentPanes` +- switching to the editor does not unmount `XtermHost` +- switching back to agent view does not remount terminal hosts +- terminal output continues while the editor is visible +- returning to agent view does not trigger new `terminal.replay` or `terminal.snapshot` requests +- covered terminals cannot accept stdin +- returning to visibility performs a refit without a recovery overlay + +### 13.2 Suggested Test Coverage + +- component or integration coverage for desktop stage composition +- `XtermHost` visibility tests for input gating and fit-on-return behavior +- e2e coverage that runs a live session, switches to the editor, waits for more output, then returns to verify continuity without replay UI + +## 14. Risks and Mitigations + +### 14.1 Hidden-Terminal DOM Cost + +Risk: + +- covered terminals still render live output, which may cost CPU and memory + +Mitigation: + +- accept this cost in phase 1 as the price of preserving terminal continuity +- evaluate runtime/view separation only if measured cost becomes material + +### 14.2 Focus Pollution + +Risk: + +- hidden terminals may continue to capture focus or keyboard input + +Mitigation: + +- disable stdin while covered +- suppress auto-focus while covered +- route foreground focus to the editor layer on mode switch + +### 14.3 Overlay Leakage + +Risk: + +- interactive terminal overlays may exist behind the editor and still affect accessibility or focus + +Mitigation: + +- gate interactive overlay mode on terminal visibility +- mark the covered agent layer inaccessible to pointer and accessibility traversal + +### 14.4 Hydration Priority Waste + +Risk: + +- covered terminals may still consume visible hydration priority + +Mitigation: + +- downgrade covered terminals to `background` hydration tier + +## 15. Phasing + +### 15.1 Phase 1 + +Implement desktop keepalive only: + +- always-mounted agent layer +- editor overlay +- `XtermHost.isVisible` +- input and overlay gating +- fit on visibility return + +### 15.2 Later Work + +If needed after measurement: + +- keepalive for editor +- terminal runtime/view separation +- persistent terminal snapshots across reloads +- hidden-terminal rendering optimizations + +## 16. Why This Design + +This design directly addresses the real failure boundary: + +- the terminal should not die just because another desktop surface is temporarily in front of it + +It keeps server recovery where it belongs: + +- as fallback for true continuity problems + +It also avoids the complexity of introducing a second frontend terminal state system before the product has exhausted the simpler option of preserving the original runtime. From 820be4ce2d75a235a4de8a3a7acaf8d6c946ff4a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:52:19 +0800 Subject: [PATCH 07/41] Add collapsible search result groups --- .../views/shared/search-panel.test.tsx | 247 +++++++++++++++--- .../workspace/views/shared/search-panel.tsx | 96 ++++--- 2 files changed, 271 insertions(+), 72 deletions(-) diff --git a/packages/web/src/features/workspace/views/shared/search-panel.test.tsx b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx index 24e5a81f..616e8124 100644 --- a/packages/web/src/features/workspace/views/shared/search-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx @@ -10,6 +10,31 @@ import { activeFilePathAtomFamily } from "../../atoms/files"; import { SearchPanel } from "./search-panel"; describe("SearchPanel", () => { + const singleMatchCountPattern = /1.*(?:matches|条匹配)/i; + + function renderSearchPanel(sendCommand: ReturnType) { + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + return { store }; + } + + async function searchFor(query: string) { + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: query }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + beforeEach(() => { vi.useFakeTimers(); }); @@ -51,22 +76,9 @@ describe("SearchPanel", () => { hasMoreFiles: true, truncatedMatchFileCount: 1, } satisfies SearchContentResult); - const store = createStore(); - store.set(wsClientAtom, { sendCommand } as never); + renderSearchPanel(sendCommand); - render( - - - - ); - - fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { - target: { value: "needle" }, - }); - - await act(async () => { - await vi.advanceTimersByTimeAsync(250); - }); + await searchFor("needle"); expect(sendCommand).toHaveBeenCalledWith( "file.searchContent", @@ -85,7 +97,7 @@ describe("SearchPanel", () => { expect(screen.getByText(/Results limited|结果已截断/i)).toBeInTheDocument(); }); - it("opens the file at the selected match location", async () => { + it("expands file groups by default after results load", async () => { const sendCommand = vi.fn().mockResolvedValue({ files: [ { @@ -109,23 +121,187 @@ describe("SearchPanel", () => { hasMoreFiles: false, truncatedMatchFileCount: 0, } satisfies SearchContentResult); - const store = createStore(); - store.set(wsClientAtom, { sendCommand } as never); - render( - - - - ); + renderSearchPanel(sendCommand); - fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { - target: { value: "needle" }, + await searchFor("needle"); + + const groupHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), }); - await act(async () => { - await vi.advanceTimersByTimeAsync(250); + expect(groupHeader).toHaveAttribute("aria-expanded", "true"); + expect(groupHeader).toHaveAttribute("aria-controls"); + expect(screen.getByRole("button", { name: /12.*needle/i })).toBeInTheDocument(); + }); + + it("collapses and re-expands file matches when the group header is clicked", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const groupHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + fireEvent.click(groupHeader); + + expect(groupHeader).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: /12.*needle/i })).not.toBeInTheDocument(); + + fireEvent.click(groupHeader); + + expect(groupHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /12.*needle/i })).toBeInTheDocument(); + }); + + it("resets returned file groups to expanded on a new successful query", async () => { + const sendCommand = vi.fn().mockImplementation(async (_op: string, args: { query: string }) => { + if (args.query === "needle") { + return { + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult; + } + + return { + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 21, + column: 3, + endColumn: 9, + preview: "startThread(worker);", + previewColumnStart: 6, + previewColumnEnd: 12, + }, + ], + }, + { + path: "src/worker.ts", + name: "worker.ts", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 4, + column: 10, + endColumn: 16, + preview: "threadPool.run(job);", + previewColumnStart: 1, + previewColumnEnd: 7, + }, + ], + }, + ], + totalMatchCount: 2, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult; + }); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const firstHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), }); + fireEvent.click(firstHeader); + expect(firstHeader).toHaveAttribute("aria-expanded", "false"); + + await searchFor("thread"); + + const appHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + const workerHeader = screen.getByRole("button", { + name: new RegExp(`worker\\.ts.*src/worker\\.ts.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(appHeader).toHaveAttribute("aria-expanded", "true"); + expect(workerHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /21.*startThread/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /4.*threadPool/i })).toBeInTheDocument(); + }); + + it("opens the file at the selected match location", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const { store } = renderSearchPanel(sendCommand); + + await searchFor("needle"); + fireEvent.click(screen.getByRole("button", { name: /12.*needle/i })); expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); @@ -140,22 +316,9 @@ describe("SearchPanel", () => { it("shows retry when the search command fails", async () => { const sendCommand = vi.fn().mockRejectedValue(new Error("boom")); - const store = createStore(); - store.set(wsClientAtom, { sendCommand } as never); - - render( - - - - ); - - fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { - target: { value: "needle" }, - }); + renderSearchPanel(sendCommand); - await act(async () => { - await vi.advanceTimersByTimeAsync(250); - }); + await searchFor("needle"); expect(screen.getByRole("button", { name: /Retry|重试/i })).toBeInTheDocument(); }); diff --git a/packages/web/src/features/workspace/views/shared/search-panel.tsx b/packages/web/src/features/workspace/views/shared/search-panel.tsx index fa96b089..b7f59f2f 100644 --- a/packages/web/src/features/workspace/views/shared/search-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/search-panel.tsx @@ -1,7 +1,8 @@ import type { SearchContentMatch, SearchContentResult } from "@coder-studio/core"; import { useAtomValue } from "jotai"; +import { ChevronDown, ChevronRight } from "lucide-react"; import type { FC, ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { dispatchCommandAtom } from "../../../../atoms/connection"; import { Button } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; @@ -25,15 +26,21 @@ function renderPreview(match: SearchContentMatch): ReactNode { ); } +function buildExpandedFileMap(results: SearchContentResult): Record { + return Object.fromEntries(results.files.map((file) => [file.path, true])); +} + export const SearchPanel: FC = ({ workspaceId }) => { const t = useTranslation(); const dispatch = useAtomValue(dispatchCommandAtom); const { openLocation } = useOpenLocation(workspaceId); const inputRef = useRef(null); const dispatchRef = useRef(dispatch); + const groupIdPrefix = useId(); const [query, setQuery] = useState(""); const [retryNonce, setRetryNonce] = useState(0); const [results, setResults] = useState(null); + const [expandedFiles, setExpandedFiles] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); @@ -49,6 +56,7 @@ export const SearchPanel: FC = ({ workspaceId }) => { const trimmed = query.trim(); if (!trimmed) { setResults(null); + setExpandedFiles({}); setLoading(false); setError(false); return; @@ -73,15 +81,18 @@ export const SearchPanel: FC = ({ workspaceId }) => { if (!result.ok || !result.data) { setResults(null); + setExpandedFiles({}); setError(true); return; } setResults(result.data); + setExpandedFiles(buildExpandedFileMap(result.data)); }) .catch(() => { if (!cancelled) { setResults(null); + setExpandedFiles({}); setError(true); } }) @@ -150,41 +161,66 @@ export const SearchPanel: FC = ({ workspaceId }) => { ) : !results || results.files.length === 0 ? (

{t("workspace.search.no_results")}

) : ( - results.files.map((file) => ( -
-
- {file.name} - {file.path} - - {t("workspace.search.file_match_count", { - count: file.matchCount, - suffix: file.hasMoreMatches ? "+" : "", - })} - -
- - {file.matches.map((match) => ( + results.files.map((file, index) => { + const matchesId = `${groupIdPrefix}-group-${index}`; + const isExpanded = expandedFiles[file.path] ?? true; + + return ( +
- ))} -
- )) + + +
+ ); + }) )}
From 571c9cd24918de61c9d930744f00a1b76609c85f Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:54:33 +0800 Subject: [PATCH 08/41] docs: add agent pane keepalive implementation plan --- .../plans/2026-05-23-agent-pane-keepalive.md | 1064 +++++++++++++++++ 1 file changed, 1064 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md diff --git a/docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md b/docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md new file mode 100644 index 00000000..11c1c103 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-agent-pane-keepalive.md @@ -0,0 +1,1064 @@ +# Agent Pane Keepalive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep desktop agent terminals alive while the editor is foregrounded so same-page `agent -> editor -> agent` switches never rebuild `xterm`, rerun replay, or lose runtime state. + +**Architecture:** Keep `AgentPanes` mounted inside a layered desktop main stage and render the editor as an overlay instead of an either-or branch. Thread a desktop visibility signal down to `XtermHost`, then use that signal to downgrade covered terminals to background hydration, disable interactivity, suppress replay overlays while covered, and refit when visible again without recreating the terminal instance. + +**Tech Stack:** React 19, TypeScript, Jotai, xterm.js, Vitest, CSS + +--- + +## File Map + +- `packages/web/src/features/agent-panes/index.tsx` + Propagates desktop visibility through the pane tree without changing pane layout behavior. +- `packages/web/src/features/agent-panes/index.test.tsx` + Verifies `AgentPanes` passes the visibility signal to every rendered `SessionCard`. +- `packages/web/src/features/agent-panes/views/shared/session-card.tsx` + Forwards the visibility signal from a session card into `XtermHost`. +- `packages/web/src/features/agent-panes/components/session-card.test.tsx` + Verifies `SessionCard` forwards `isVisible` to the terminal host. +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` + Converts the desktop main stage from conditional rendering to layered rendering and passes desktop foreground visibility into `AgentPanes`. +- `packages/web/src/features/workspace/index.test.tsx` + Verifies desktop editor mode keeps `AgentPanes` mounted and only toggles foreground visibility. +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` + Makes terminal interactivity, hydration priority, replay overlay visibility, and refit behavior aware of the new `isVisible` prop. +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + Verifies hidden terminals downgrade to background, stop accepting input, do not replay again on visibility-only rerenders, refit without refocusing, and suppress closed-session overlays while covered. +- `packages/web/src/styles/components.css` + Adds layered desktop stage styles for the always-mounted agent layer and editor overlay. +- `packages/web/src/styles/components.theme.test.ts` + Verifies the new desktop stage selectors and layout rules exist. + +## Guardrails + +- Leave `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` unchanged. `mainAreaMode` remains a foreground selector, not a mount selector. +- Do not change mobile behavior in this plan. +- Do not add a frontend terminal snapshot cache or runtime manager in this plan. +- Do not remove `terminal.replay` or `terminal.snapshot`; they stay as fallback for true recovery. + +### Task 1: Thread Desktop Visibility Through AgentPanes and SessionCard + +**Files:** +- Modify: `packages/web/src/features/agent-panes/index.tsx` +- Modify: `packages/web/src/features/agent-panes/index.test.tsx` +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` + +- [ ] **Step 1: Write the failing prop-threading tests** + +Add this test to `packages/web/src/features/agent-panes/index.test.tsx`: + +```tsx +type MockSessionCardProps = { + sessionId: string; + isVisible?: boolean; + onSplitHorizontal?: () => void; + onSplitVertical?: () => void; + onClose?: () => void; +}; + +it("passes visibility to session cards", async () => { + const { store } = createAgentPaneStore(); + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(mockSessionCard).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: "sess_1", + isVisible: true, + }) + ); + }); + + rerender( + + + + ); + + await waitFor(() => { + expect(mockSessionCard).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: "sess_1", + isVisible: false, + }) + ); + }); +}); +``` + +Add this test to `packages/web/src/features/agent-panes/components/session-card.test.tsx`: + +```tsx +it("passes terminal visibility through to XtermHost", () => { + const { store } = createSessionStore({ + terminalId: "term-live", + state: "running", + endedAt: undefined, + }); + + render( + + + + ); + + expect(getLastXtermHostProps()).toEqual( + expect.objectContaining({ + terminalId: "term-live", + isVisible: false, + }) + ); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/agent-panes/index.test.tsx -t "passes visibility to session cards" +pnpm --filter @coder-studio/web test -- src/features/agent-panes/components/session-card.test.tsx -t "passes terminal visibility through to XtermHost" +``` + +Expected: + +```text +FAIL src/features/agent-panes/index.test.tsx + AssertionError: expected last call to contain { isVisible: true } + +FAIL src/features/agent-panes/components/session-card.test.tsx + AssertionError: expected object to contain { isVisible: false } +``` + +- [ ] **Step 3: Implement the prop threading with default-visible behavior** + +In `packages/web/src/features/agent-panes/index.tsx`, replace the `AgentPanesProps` declaration and component signature with: + +```tsx +interface AgentPanesProps { + hydrateSessions?: boolean; + isVisible?: boolean; +} + +export const AgentPanes: FC = ({ + hydrateSessions = true, + isVisible = true, +}) => { + const t = useTranslation(); + const workspace = useAtomValue(activeWorkspaceAtom); + const { workspaceId, sessions, paneLayout } = useWorkspaceSessions(workspace, { + disabled: !hydrateSessions, + }); + const paneActions = usePaneActions(workspaceId); + const sessionActions = useSessionActions(); + const hasLayoutSessions = collectSessionIds(paneLayout).length > 0; + const shouldShowStandaloneDraftLauncher = + sessions.length === 0 && + (hasLayoutSessions || + (paneLayout.type === "leaf" && !paneLayout.sessionId && paneLayout.id === "root")); + + if (!workspace) { + return ( +
+ {t("workspace.no_workspace")}

} + /> +
+ ); + } + + if (shouldShowStandaloneDraftLauncher) { + return ( + + ); + } + + return ( +
+ +
+ ); +}; +``` + +In the same file, replace `PaneNodeRendererProps` and the `SessionCard` render branch with: + +```tsx +interface PaneNodeRendererProps { + node: PaneNode; + workspaceId: string; + isVisible: boolean; + onAssignSession: (paneId: string, sessionId: string) => void; + onCloseDraftPane: (paneId: string) => void; + onCloseSession: (sessionId: string) => void; + onCloseSessionCommand: ( + sessionId: string, + paneDisposition?: "draft" | "remove" + ) => Promise; + onReplaceWithSession: (sessionId: string) => void; + onSplitDraftPane: (paneId: string, direction: "horizontal" | "vertical") => void; + onSplitSession: (sessionId: string, direction: "horizontal" | "vertical") => void; +} + +const PaneNodeRenderer: FC = ({ + node, + workspaceId, + isVisible, + onAssignSession, + onCloseDraftPane, + onCloseSession, + onCloseSessionCommand, + onReplaceWithSession, + onSplitDraftPane, + onSplitSession, +}) => { + if (node.type === "leaf") { + if (node.sessionId) { + return ( + { + onCloseSession(node.sessionId!); + await onCloseSessionCommand(node.sessionId!, "draft"); + }} + onSplitHorizontal={() => onSplitSession(node.sessionId!, "horizontal")} + onSplitVertical={() => onSplitSession(node.sessionId!, "vertical")} + /> + ); + } + + return ( + + ); + } + + const resolvedRatio = readPaneRatio(workspaceId, node.id) ?? node.ratio ?? 0.5; + + return ( + writePaneRatio(workspaceId, node.id, ratio)} + > + {node.children?.map((child) => ( + + ))} + + ); +}; +``` + +In `packages/web/src/features/agent-panes/views/shared/session-card.tsx`, replace the props definition and `XtermHost` usage with: + +```tsx +interface SessionCardProps { + sessionId: string; + isVisible?: boolean; + showHeaderActions?: boolean; + showSupervisorInline?: boolean; + terminalReadOnlyOverride?: boolean; + headerAccessory?: ReactNode; + onClose?: SessionCardAction; + onSplitHorizontal?: SessionCardAction; + onSplitVertical?: SessionCardAction; +} + +export const SessionCard: FC = ({ + sessionId, + isVisible = true, + showHeaderActions = true, + showSupervisorInline = true, + terminalReadOnlyOverride, + headerAccessory, + onClose, + onSplitHorizontal, + onSplitVertical, +}) => { +``` + +```tsx +
+ { + void handleClosedSessionClose(); + }} + onClosedSessionContinue={() => { + void handleClosedSessionContinue(); + }} + terminalId={session.terminalId} + workspaceId={session.workspaceId} + readOnly={terminalReadOnly} + isActiveSession={isActiveSession} + isVisible={isVisible} + terminalKind="agent" + /> +
+``` + +- [ ] **Step 4: Run the focused tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/agent-panes/index.test.tsx -t "passes visibility to session cards" +pnpm --filter @coder-studio/web test -- src/features/agent-panes/components/session-card.test.tsx -t "passes terminal visibility through to XtermHost" +``` + +Expected: + +```text +PASS src/features/agent-panes/index.test.tsx +PASS src/features/agent-panes/components/session-card.test.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/agent-panes/index.tsx \ + packages/web/src/features/agent-panes/index.test.tsx \ + packages/web/src/features/agent-panes/views/shared/session-card.tsx \ + packages/web/src/features/agent-panes/components/session-card.test.tsx +git commit -m "refactor: thread desktop terminal visibility" +``` + +### Task 2: Keep AgentPanes Mounted in the Desktop Stage + +**Files:** +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing desktop keepalive and CSS tests** + +In `packages/web/src/features/workspace/index.test.tsx`, change the `AgentPanes` mock to expose lifecycle and visibility: + +```tsx +const agentPaneLifecycle = { mounts: 0, unmounts: 0 }; + +vi.mock("../agent-panes", async () => { + const React = await vi.importActual("react"); + + return { + AgentPanes: ({ isVisible = true }: { isVisible?: boolean }) => { + React.useEffect(() => { + agentPaneLifecycle.mounts += 1; + return () => { + agentPaneLifecycle.unmounts += 1; + }; + }, []); + + return
; + }, + }; +}); +``` + +Reset the lifecycle object in the existing `afterEach` block: + +```tsx +agentPaneLifecycle.mounts = 0; +agentPaneLifecycle.unmounts = 0; +``` + +Add this new workspace test: + +```tsx +it("keeps agent panes mounted beneath the editor overlay", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + await screen.findByTestId("agent-panes"); + expect(agentPaneLifecycle).toEqual({ mounts: 1, unmounts: 0 }); + + act(() => { + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + }); + + expect(screen.getByTestId("agent-panes")).toBeInTheDocument(); + expect(screen.getByTestId("agent-panes")).toHaveAttribute("data-visible", "false"); + expect(screen.getByTestId("code-editor-host")).toBeInTheDocument(); + expect(agentPaneLifecycle).toEqual({ mounts: 1, unmounts: 0 }); + expect(document.querySelector(".workspace-main-stage__agent-layer")).toHaveAttribute( + "aria-hidden", + "true" + ); + expect(document.querySelector(".workspace-main-stage__editor-layer")).not.toBeNull(); +}); +``` + +Replace the two editor-mode assertions that currently remove agent panes with these assertions: + +```tsx +expect(screen.getByTestId("code-editor-host")).toBeInTheDocument(); +expect(screen.getByTestId("agent-panes")).toHaveAttribute("data-visible", "false"); +``` + +In the existing desktop surface test inside `packages/web/src/styles/components.theme.test.ts`, replace the old stage selector capture: + +```ts +const agentPanes = getLastRuleBlock(".workspace-main-stage > .agent-panes"); +``` + +with: + +```ts +const agentLayer = getLastRuleBlock(".workspace-main-stage__agent-layer"); +const coveredAgentLayer = getLastRuleBlock(".workspace-main-stage__agent-layer--covered"); +const editorLayer = getLastRuleBlock(".workspace-main-stage__editor-layer"); +const nestedAgentPanes = getLastRuleBlock(".workspace-main-stage__agent-layer > .agent-panes"); +``` + +Replace the old agent-pane expectations: + +```ts +expect(agentPanes).toContain("flex: 1"); +expect(agentPanes).toContain("min-height: 0"); +expect(agentPanes).toContain("padding: 0"); +``` + +with: + +```ts +expect(mainStage).toContain("position: relative"); +expect(mainStage).toContain("overflow: hidden"); +expect(agentLayer).toContain("display: flex"); +expect(agentLayer).toContain("min-height: 0"); +expect(coveredAgentLayer).toContain("pointer-events: none"); +expect(editorLayer).toContain("position: absolute"); +expect(editorLayer).toContain("inset: 0"); +expect(nestedAgentPanes).toContain("flex: 1"); +expect(nestedAgentPanes).toContain("min-height: 0"); +expect(nestedAgentPanes).toContain("padding: 0"); +``` +``` + +- [ ] **Step 2: Run the focused desktop tests and verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/index.test.tsx -t "keeps agent panes mounted beneath the editor overlay" +pnpm --filter @coder-studio/web test -- src/styles/components.theme.test.ts -t "emits layered desktop workspace stage rules" +``` + +Expected: + +```text +FAIL src/features/workspace/index.test.tsx + Unable to find an element by: [data-testid="agent-panes"] + +FAIL src/styles/components.theme.test.ts + expected CSS rule for .workspace-main-stage__agent-layer +``` + +- [ ] **Step 3: Layer the desktop stage and keep AgentPanes mounted** + +In `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx`, replace the current main-stage branch with: + +```tsx +
+
+
+ +
+ + {mainAreaMode === "editor" ? ( +
+ +
+ ) : null} +
+``` + +In `packages/web/src/styles/components.css`, replace the current desktop stage block with: + +```css +.workspace-main-stage { + position: relative; + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.workspace-main-stage__agent-layer { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; +} + +.workspace-main-stage__agent-layer > .agent-panes { + flex: 1; + min-height: 0; + padding: 0; +} + +.workspace-main-stage__agent-layer--covered { + pointer-events: none; +} + +.workspace-main-stage__editor-layer { + position: absolute; + inset: 0; + z-index: 1; + min-width: 0; + min-height: 0; +} + +.workspace-main-stage__editor-layer > * { + height: 100%; + min-height: 0; +} +``` + +- [ ] **Step 4: Run the focused desktop tests and verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/index.test.tsx -t "keeps agent panes mounted beneath the editor overlay" +pnpm --filter @coder-studio/web test -- src/styles/components.theme.test.ts -t "emits layered desktop workspace stage rules" +``` + +Expected: + +```text +PASS src/features/workspace/index.test.tsx +PASS src/styles/components.theme.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx \ + packages/web/src/features/workspace/index.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat: keep desktop agent panes mounted" +``` + +### Task 3: Make XtermHost Visibility-Aware Without Recreating xterm + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` + +- [ ] **Step 1: Write the failing visibility-behavior tests** + +Add this test to `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx`: + +```tsx +it("treats covered desktop terminals as background hydration and disables stdin", async () => { + const store = createStore(); + store.set(wsClientAtom, { + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + expect(hydrationCoordinatorMocks.request).toHaveBeenCalledWith({ + terminalId: "covered-terminal", + tier: "background", + }); + + await waitFor(() => { + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + disableStdin: true, + cursorBlink: false, + }) + ); + }); +}); +``` + +Add this test to the same file: + +```tsx +it("does not rerun recovery when only desktop visibility changes", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + + if (op === "terminal.replay") { + return Promise.resolve({ status: "ok", seq: 0 }); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + const { rerender } = render( + + + + ); + + await act(async () => { + rafCallbacks.shift()?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + mockFitAddon.fit.mockClear(); + mockTerminal.focus.mockClear(); + + rerender( + + + + ); + + rerender( + + + + ); + + await act(async () => { + await Promise.resolve(); + }); + + const { Terminal } = await import("@xterm/xterm"); + + expect(Terminal).toHaveBeenCalledTimes(1); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.snapshot")).toHaveLength(1); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.replay")).toHaveLength(1); + expect(mockFitAddon.fit).toHaveBeenCalled(); + expect(mockTerminal.focus).not.toHaveBeenCalled(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; +}); +``` + +Add this test to the same file: + +```tsx +it("suppresses the closed-session overlay while the terminal is covered", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.replay") { + return Promise.resolve({ status: "unknown" }); + } + + return Promise.resolve({ ok: true, data: { status: "ok" } }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + const { rerender } = render( + + + + ); + + await act(async () => { + rafCallbacks.shift()?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.queryByText("当前会话已结束")).not.toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); + + rerender( + + + + ); + + await waitFor(() => { + expect(screen.getByText("当前会话已结束")).toBeInTheDocument(); + }); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; +}); +``` + +- [ ] **Step 2: Run the terminal host test file and verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +```text +FAIL src/features/terminal-panel/__tests__/xterm-host.test.tsx + Property 'isVisible' does not exist on type 'XtermHostProps' + expected hydration request tier to equal "background" + expected terminal.replay call count to remain 1 + expected overlay not to be rendered while hidden +``` + +- [ ] **Step 3: Add visibility-aware hydration, interactivity, overlay gating, and refit logic** + +In `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`, add a shared helper near the prop types: + +```tsx +function resolveHydrationTier({ + alive, + isActiveSession, + isVisible, +}: { + alive: boolean | undefined; + isActiveSession: boolean; + isVisible: boolean; +}): HydrationTier { + if (!isVisible || alive === false) { + return "background"; + } + + return isActiveSession ? "visible-active" : "visible-other"; +} +``` + +Replace the `XtermHostProps` block and function signature with: + +```tsx +interface XtermHostProps { + terminalId: string; + workspaceId: string; + readOnly?: boolean; + isActiveSession?: boolean; + isVisible?: boolean; + terminalKind?: "agent" | "shell"; + containerRef?: React.RefObject; + closedSessionContinueLabel?: string; + closedSessionProviderLabel?: string; + onClosedSessionContinue?: () => void; + onClosedSessionClose?: () => void; +} + +export function XtermHost({ + closedSessionContinueLabel, + closedSessionProviderLabel, + onClosedSessionClose, + onClosedSessionContinue, + terminalId, + workspaceId, + readOnly = false, + isActiveSession = false, + isVisible = true, + terminalKind: terminalKindProp, +}: XtermHostProps) { +``` + +Replace the interactivity and visibility refs near the top of the component with: + +```tsx + const terminalMetaRef = useRef(meta); + const terminalKind = terminalKindProp ?? meta?.kind ?? "shell"; + const isInteractive = isVisible && !readOnly && meta?.alive !== false; + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + const fitFrameRef = useRef(null); + const mountedRef = useRef(false); + const fitResolversRef = useRef void>>([]); + const resizeDebounceRef = useRef | null>(null); + const interactiveRef = useRef(true); + const visibleRef = useRef(isVisible); + const previousVisibleRef = useRef(isVisible); + const lastReportedSizeRef = useRef<{ cols: number; rows: number } | null>(null); +``` + +Add this effect after the `terminalMetaRef` sync effect: + +```tsx + useEffect(() => { + visibleRef.current = isVisible; + }, [isVisible]); +``` + +Replace both hydration-tier calculations with the shared helper: + +```tsx + const handle = globalHydrationCoordinator.request({ + terminalId, + tier: resolveHydrationTier({ + alive: meta?.alive, + isActiveSession, + isVisible, + }), + }); +``` + +```tsx + hydrationHandleRef.current?.promote( + resolveHydrationTier({ + alive: meta?.alive, + isActiveSession, + isVisible, + }) + ); + }, [isActiveSession, isVisible, meta?.alive, viewport]); +``` + +Keep the existing focus effect dependency list stable, but gate focus with the visibility ref so covered terminals never refocus: + +```tsx + useEffect(() => { + if ( + viewport !== "mobile" && + hydrationState.kind === "granted" && + meta?.alive && + terminalRef.current && + visibleRef.current + ) { + terminalRef.current.focus(); + } + }, [hydrationState.kind, meta?.alive, viewport]); +``` + +Add a new visibility-restore refit effect immediately after the focus effect: + +```tsx + useEffect(() => { + const wasVisible = previousVisibleRef.current; + previousVisibleRef.current = isVisible; + + if (viewport === "mobile") { + return; + } + + if (!isVisible || wasVisible || hydrationState.kind !== "granted") { + return; + } + + if (terminalRef.current) { + scheduleFit(); + } + }, [hydrationState.kind, isVisible, scheduleFit, viewport]); +``` + +Finally, gate the replay overlay by visibility: + +```tsx + const showReplayOverlay = + (isVisible || viewport === "mobile") && + (replayUiState.kind === "degraded" || + (replayUiState.kind === "loading" && loadingOverlayVisible)) && + (viewport === "mobile" || + hydrationState.kind === "granted" || + activeRecoveryUiModeRef.current === "non_blocking_recovering"); +``` + +- [ ] **Step 4: Run the terminal host test file and verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +Expected: + +```text +PASS src/features/terminal-panel/__tests__/xterm-host.test.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +git commit -m "feat: make xterm keepalive visibility-aware" +``` + +## Final Verification + +Run: + +```bash +pnpm --filter @coder-studio/web test -- \ + src/features/agent-panes/index.test.tsx \ + src/features/agent-panes/components/session-card.test.tsx \ + src/features/workspace/index.test.tsx \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: + +```text +PASS src/features/agent-panes/index.test.tsx +PASS src/features/agent-panes/components/session-card.test.tsx +PASS src/features/workspace/index.test.tsx +PASS src/features/terminal-panel/__tests__/xterm-host.test.tsx +PASS src/styles/components.theme.test.ts +``` + +If you want one extra safety check after the test run, use: + +```bash +pnpm --filter @coder-studio/web exec tsc -p tsconfig.json --noEmit +``` + +Expected: + +```text +Found 0 errors. +``` From d46fc13df35d1aa041098253b6e50aa0bf5e7320 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:58:37 +0800 Subject: [PATCH 09/41] Refine quick open rows --- .../quick-open/components/quick-open.test.tsx | 36 ++++++++++++++----- .../quick-open/components/quick-open.tsx | 8 +++-- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/web/src/features/quick-open/components/quick-open.test.tsx b/packages/web/src/features/quick-open/components/quick-open.test.tsx index 823ce533..21e612a7 100644 --- a/packages/web/src/features/quick-open/components/quick-open.test.tsx +++ b/packages/web/src/features/quick-open/components/quick-open.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { quickOpenOpenAtom } from "../../../atoms/app-ui"; @@ -44,7 +44,7 @@ describe("QuickOpen", () => { vi.useRealTimers(); }); - it("opens on Ctrl/Cmd+P and queries file.search for the active workspace", async () => { + it("opens on Ctrl/Cmd+P, queries file.search, and renders file name with path", async () => { const sendCommand = vi.fn().mockResolvedValue({ files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], }); @@ -77,12 +77,19 @@ describe("QuickOpen", () => { undefined ); - expect(screen.getByText("app.tsx")).toBeInTheDocument(); + const result = screen.getByRole("button", { name: /app\.tsx/i }); + expect(within(result).getByText("app.tsx")).toHaveClass("quick-open__name"); + expect(within(result).getByText("src/app.tsx")).toHaveClass("quick-open__path"); + expect(result.querySelector(".quick-open__primary")).not.toBeNull(); + expect(result.querySelector(".quick-open__secondary")).not.toBeNull(); }); - it("opens the selected file and closes after Enter", async () => { + it("moves the active row with keyboard and opens the selected file on Enter", async () => { const sendCommand = vi.fn().mockResolvedValue({ - files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + files: [ + { path: "src/app.tsx", name: "app.tsx", kind: "file" }, + { path: "src/routes.ts", name: "routes.ts", kind: "file" }, + ], }); const store = createStore(); store.set(wsClientAtom, { sendCommand } as never); @@ -103,12 +110,25 @@ describe("QuickOpen", () => { await vi.advanceTimersByTimeAsync(150); }); - expect(screen.getByText("app.tsx")).toBeInTheDocument(); - fireEvent.keyDown(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + const input = screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }); + const firstResult = screen.getByRole("button", { name: /app\.tsx/i }); + const secondResult = screen.getByRole("button", { name: /routes\.ts/i }); + + expect(firstResult).toHaveClass("quick-open__item--active"); + expect(secondResult).not.toHaveClass("quick-open__item--active"); + + fireEvent.keyDown(input, { + key: "ArrowDown", + }); + + expect(firstResult).not.toHaveClass("quick-open__item--active"); + expect(secondResult).toHaveClass("quick-open__item--active"); + + fireEvent.keyDown(input, { key: "Enter", }); - expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/routes.ts"); expect(store.get(quickOpenOpenAtom)).toBe(false); }); }); diff --git a/packages/web/src/features/quick-open/components/quick-open.tsx b/packages/web/src/features/quick-open/components/quick-open.tsx index 863925f6..c88bfa0a 100644 --- a/packages/web/src/features/quick-open/components/quick-open.tsx +++ b/packages/web/src/features/quick-open/components/quick-open.tsx @@ -206,8 +206,12 @@ export function QuickOpen() { setOpen(false); }} > - {file.name} - {file.path} + + {file.name} + + + {file.path} + )) )} From 899b8682c98d0f9d75311d4a09dc8fd377b2000c Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sat, 23 May 2026 17:58:37 +0800 Subject: [PATCH 10/41] Refine workspace search and quick open chrome --- .../quick-open/components/quick-open.test.tsx | 20 +- .../quick-open/components/quick-open.tsx | 58 +-- .../views/shared/search-panel.test.tsx | 131 +++++++ .../workspace/views/shared/search-panel.tsx | 14 +- packages/web/src/styles/components.css | 338 ++++++++++++++++++ .../web/src/styles/components.theme.test.ts | 51 +++ 6 files changed, 568 insertions(+), 44 deletions(-) diff --git a/packages/web/src/features/quick-open/components/quick-open.test.tsx b/packages/web/src/features/quick-open/components/quick-open.test.tsx index 21e612a7..a54c30f3 100644 --- a/packages/web/src/features/quick-open/components/quick-open.test.tsx +++ b/packages/web/src/features/quick-open/components/quick-open.test.tsx @@ -77,11 +77,9 @@ describe("QuickOpen", () => { undefined ); - const result = screen.getByRole("button", { name: /app\.tsx/i }); - expect(within(result).getByText("app.tsx")).toHaveClass("quick-open__name"); - expect(within(result).getByText("src/app.tsx")).toHaveClass("quick-open__path"); - expect(result.querySelector(".quick-open__primary")).not.toBeNull(); - expect(result.querySelector(".quick-open__secondary")).not.toBeNull(); + const result = screen.getByRole("option", { name: /app\.tsx/i }); + expect(within(result).getByText("app.tsx")).toHaveClass("quick-open__primary"); + expect(within(result).getByText("src/app.tsx")).toHaveClass("quick-open__secondary"); }); it("moves the active row with keyboard and opens the selected file on Enter", async () => { @@ -111,18 +109,18 @@ describe("QuickOpen", () => { }); const input = screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }); - const firstResult = screen.getByRole("button", { name: /app\.tsx/i }); - const secondResult = screen.getByRole("button", { name: /routes\.ts/i }); + const firstResult = screen.getByRole("option", { name: /app\.tsx/i }); + const secondResult = screen.getByRole("option", { name: /routes\.ts/i }); - expect(firstResult).toHaveClass("quick-open__item--active"); - expect(secondResult).not.toHaveClass("quick-open__item--active"); + expect(firstResult).toHaveAttribute("aria-selected", "true"); + expect(secondResult).toHaveAttribute("aria-selected", "false"); fireEvent.keyDown(input, { key: "ArrowDown", }); - expect(firstResult).not.toHaveClass("quick-open__item--active"); - expect(secondResult).toHaveClass("quick-open__item--active"); + expect(firstResult).toHaveAttribute("aria-selected", "false"); + expect(secondResult).toHaveAttribute("aria-selected", "true"); fireEvent.keyDown(input, { key: "Enter", diff --git a/packages/web/src/features/quick-open/components/quick-open.tsx b/packages/web/src/features/quick-open/components/quick-open.tsx index c88bfa0a..47790586 100644 --- a/packages/web/src/features/quick-open/components/quick-open.tsx +++ b/packages/web/src/features/quick-open/components/quick-open.tsx @@ -185,35 +185,35 @@ export function QuickOpen() { ) : results.length === 0 ? (

{t("quick_open.no_results")}

) : ( - results.map((file, index) => ( - - )) +
+ {results.map((file, index) => ( + + ))} +
)}
diff --git a/packages/web/src/features/workspace/views/shared/search-panel.test.tsx b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx index 616e8124..693c1175 100644 --- a/packages/web/src/features/workspace/views/shared/search-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/search-panel.test.tsx @@ -274,6 +274,60 @@ describe("SearchPanel", () => { expect(screen.getByRole("button", { name: /4.*threadPool/i })).toBeInTheDocument(); }); + it("clears collapsed group state when the query is cleared", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + const groupHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + fireEvent.click(groupHeader); + expect(groupHeader).toHaveAttribute("aria-expanded", "false"); + + await searchFor(""); + + expect( + screen.queryByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }) + ).not.toBeInTheDocument(); + + await searchFor("needle"); + + const nextHeader = screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(nextHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /12.*needle/i })).toBeInTheDocument(); + }); + it("opens the file at the selected match location", async () => { const sendCommand = vi.fn().mockResolvedValue({ files: [ @@ -322,4 +376,81 @@ describe("SearchPanel", () => { expect(screen.getByRole("button", { name: /Retry|重试/i })).toBeInTheDocument(); }); + + it("re-expands file groups after a failed search is retried successfully", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ + files: [ + { + path: "src/thread.ts", + name: "thread.ts", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 4, + column: 10, + endColumn: 16, + preview: "threadPool.run(job);", + previewColumnStart: 1, + previewColumnEnd: 7, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + + renderSearchPanel(sendCommand); + + await searchFor("needle"); + + fireEvent.click( + screen.getByRole("button", { + name: new RegExp(`app\\.tsx.*src/app\\.tsx.*${singleMatchCountPattern.source}`, "i"), + }) + ); + + await searchFor("thread"); + + fireEvent.click(screen.getByRole("button", { name: /Retry|重试/i })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + const retryHeader = screen.getByRole("button", { + name: new RegExp(`thread\\.ts.*src/thread\\.ts.*${singleMatchCountPattern.source}`, "i"), + }); + + expect(retryHeader).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /4.*threadPool/i })).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/features/workspace/views/shared/search-panel.tsx b/packages/web/src/features/workspace/views/shared/search-panel.tsx index b7f59f2f..340ac5e8 100644 --- a/packages/web/src/features/workspace/views/shared/search-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/search-panel.tsx @@ -182,9 +182,11 @@ export const SearchPanel: FC = ({ workspaceId }) => { - {file.name} - {file.path} - + + {file.name} + {file.path} + + {t("workspace.search.file_match_count", { count: file.matchCount, suffix: file.hasMoreMatches ? "+" : "", @@ -192,7 +194,11 @@ export const SearchPanel: FC = ({ workspaceId }) => { -