From 779c1d0bb0fb6292f2141602a41e9d421ebd3eed Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 29 Dec 2025 18:15:48 +0200 Subject: [PATCH 01/64] feat(desktop): add Workbench/Review mode with FileViewer panes Introduces a workspace-level view mode toggle allowing users to switch between: - **Workbench mode**: Mosaic panes layout with terminals + file viewers for in-flow work - **Review mode**: Dedicated Changes page for focused code review Key changes: - Add ViewModeToggle component in workspace header (prominent segmented control) - Add FileViewerPane with Raw/Rendered/Diff modes, lock/unlock, and split support - Add GroupStrip for group switching above Mosaic content - Unify sidebar to use full ChangesView in both modes (with onFileOpen callback) - Add workspace-view-mode store with per-workspace persistence - Add readWorkingFile tRPC procedure for safe file reads (size/binary checks) - Wire file clicks to open/reuse FileViewer panes (MRU unlocked policy) - Cmd+T in Review mode switches to Workbench first, then creates terminal --- .../lib/trpc/routers/changes/file-contents.ts | 133 ++++- .../TabsContent/GroupStrip/GroupStrip.tsx | 151 +++++ .../TabsContent/GroupStrip/index.ts | 1 + .../TabView/FileViewerPane/FileViewerPane.tsx | 537 ++++++++++++++++++ .../TabView/FileViewerPane/index.ts | 1 + .../ContentView/TabsContent/TabView/index.tsx | 25 + .../ContentView/TabsContent/index.tsx | 10 +- .../WorkspaceView/ContentView/index.tsx | 17 +- .../Sidebar/ChangesView/ChangesView.tsx | 15 +- .../WorkspaceView/Sidebar/index.tsx | 49 +- .../WorkspaceActionBar/WorkspaceActionBar.tsx | 8 +- .../ViewModeToggle/ViewModeToggle.tsx | 55 ++ .../components/ViewModeToggle/index.ts | 1 + .../main/components/WorkspaceView/index.tsx | 18 +- apps/desktop/src/renderer/stores/index.ts | 1 + .../desktop/src/renderer/stores/tabs/store.ts | 109 +++- .../desktop/src/renderer/stores/tabs/types.ts | 15 + .../desktop/src/renderer/stores/tabs/utils.ts | 61 ++ .../renderer/stores/workspace-view-mode.ts | 56 ++ apps/desktop/src/shared/tabs-types.ts | 35 +- 20 files changed, 1267 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts create mode 100644 apps/desktop/src/renderer/stores/workspace-view-mode.ts diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 8af04dd68e4..29f035a058f 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,11 +1,78 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { lstat, readFile, realpath, writeFile } from "node:fs/promises"; +import { isAbsolute, join, normalize, relative } from "node:path"; import type { FileContents } from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { detectLanguage } from "./utils/parse-status"; +/** Maximum file size for reading (2 MiB) */ +const MAX_FILE_SIZE = 2 * 1024 * 1024; + +/** Bytes to scan for binary detection */ +const BINARY_CHECK_SIZE = 8192; + +/** + * Result type for readWorkingFile procedure + */ +type ReadWorkingFileResult = + | { ok: true; content: string; truncated: boolean; byteLength: number } + | { + ok: false; + reason: "not-found" | "too-large" | "binary" | "outside-worktree"; + }; + +/** + * Validates that a file path is within the worktree and doesn't escape via symlinks + */ +async function validatePathInWorktree( + worktreePath: string, + filePath: string, +): Promise<{ valid: boolean; resolvedPath?: string; reason?: string }> { + // Reject absolute paths + if (isAbsolute(filePath)) { + return { valid: false, reason: "outside-worktree" }; + } + + // Normalize and check for traversal + const normalizedPath = normalize(filePath); + if (normalizedPath.startsWith("..") || normalizedPath.includes("/../")) { + return { valid: false, reason: "outside-worktree" }; + } + + const fullPath = join(worktreePath, normalizedPath); + + // Resolve symlinks and verify the real path is still within worktree + try { + const realWorktreePath = await realpath(worktreePath); + const realFilePath = await realpath(fullPath); + const relativePath = relative(realWorktreePath, realFilePath); + + // If relative path starts with "..", the file is outside worktree + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + return { valid: false, reason: "outside-worktree" }; + } + + return { valid: true, resolvedPath: realFilePath }; + } catch { + // File doesn't exist + return { valid: false, reason: "not-found" }; + } +} + +/** + * Detects if a buffer contains binary content by checking for NUL bytes + */ +function isBinaryContent(buffer: Buffer): boolean { + const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i++) { + if (buffer[i] === 0) { + return true; + } + } + return false; +} + export const createFileContentsRouter = () => { return router({ getFileContents: publicProcedure @@ -54,6 +121,68 @@ export const createFileContentsRouter = () => { await writeFile(fullPath, input.content, "utf-8"); return { success: true }; }), + + /** + * Read a working tree file safely with size cap and binary detection. + * Used for File Viewer raw/rendered modes. + * Follows DL-005 (path validation) and DL-008 (size/binary policy). + */ + readWorkingFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .query(async ({ input }): Promise => { + // Validate path is within worktree + const validation = await validatePathInWorktree( + input.worktreePath, + input.filePath, + ); + + if (!validation.valid || !validation.resolvedPath) { + return { + ok: false, + reason: (validation.reason ?? "not-found") as + | "not-found" + | "outside-worktree", + }; + } + + const resolvedPath = validation.resolvedPath; + + // Check file size + try { + const stats = await lstat(resolvedPath); + if (stats.size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + } catch { + return { ok: false, reason: "not-found" }; + } + + // Read file content + let buffer: Buffer; + try { + buffer = await readFile(resolvedPath); + } catch { + return { ok: false, reason: "not-found" }; + } + + // Check for binary content + if (isBinaryContent(buffer)) { + return { ok: false, reason: "binary" }; + } + + // Return content as string + return { + ok: true, + content: buffer.toString("utf-8"), + truncated: false, + byteLength: buffer.length, + }; + }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx new file mode 100644 index 00000000000..e4aa4e47352 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -0,0 +1,151 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useMemo } from "react"; +import { HiMiniPlus, HiMiniXMark } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { getTabDisplayName } from "renderer/stores/tabs/utils"; + +interface GroupItemProps { + tab: Tab; + isActive: boolean; + needsAttention: boolean; + onSelect: () => void; + onClose: () => void; +} + +function GroupItem({ + tab, + isActive, + needsAttention, + onSelect, + onClose, +}: GroupItemProps) { + const displayName = getTabDisplayName(tab); + + return ( +
+ + + + + + {displayName} + + + +
+ ); +} + +export function GroupStrip() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id; + + const allTabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const activeTabIds = useTabsStore((s) => s.activeTabIds); + const addTab = useTabsStore((s) => s.addTab); + const removeTab = useTabsStore((s) => s.removeTab); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + + const tabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId) + : [], + [activeWorkspaceId, allTabs], + ); + + const activeTabId = activeWorkspaceId + ? activeTabIds[activeWorkspaceId] + : null; + + // Check which tabs have panes that need attention + const tabsWithAttention = useMemo(() => { + const result = new Set(); + for (const pane of Object.values(panes)) { + if (pane.needsAttention) { + result.add(pane.tabId); + } + } + return result; + }, [panes]); + + const handleAddGroup = () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }; + + const handleSelectGroup = (tabId: string) => { + if (activeWorkspaceId) { + setActiveTab(activeWorkspaceId, tabId); + } + }; + + const handleCloseGroup = (tabId: string) => { + removeTab(tabId); + }; + + if (tabs.length === 0) { + return null; + } + + return ( +
+
+ {tabs.map((tab) => ( + handleSelectGroup(tab.id)} + onClose={() => handleCloseGroup(tab.id)} + /> + ))} +
+ + + + + + New Group + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts new file mode 100644 index 00000000000..e905a6c8bad --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts @@ -0,0 +1 @@ +export { GroupStrip } from "./GroupStrip"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx new file mode 100644 index 00000000000..a9dac12d7a5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -0,0 +1,537 @@ +import Editor, { type OnMount } from "@monaco-editor/react"; +import { Badge } from "@superset/ui/badge"; +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import type * as Monaco from "monaco-editor"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + HiMiniLockClosed, + HiMiniLockOpen, + HiMiniPencil, + HiMiniXMark, +} from "react-icons/hi2"; +import { LuLoader } from "react-icons/lu"; +import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import type { MosaicBranch } from "react-mosaic-component"; +import { MosaicWindow } from "react-mosaic-component"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { + monaco, + SUPERSET_THEME, + useMonacoReady, +} from "renderer/contexts/MonacoProvider"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Pane } from "renderer/stores/tabs/types"; +import type { FileViewerMode } from "shared/tabs-types"; +import { DiffViewer } from "../../../ChangesContent/components/DiffViewer"; + +type SplitOrientation = "vertical" | "horizontal"; + +/** Client-side language detection for Monaco editor */ +function detectLanguage(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + const languageMap: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + json: "json", + md: "markdown", + mdx: "markdown", + css: "css", + scss: "scss", + less: "less", + html: "html", + xml: "xml", + yaml: "yaml", + yml: "yaml", + py: "python", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + sh: "shell", + bash: "shell", + zsh: "shell", + sql: "sql", + graphql: "graphql", + gql: "graphql", + }; + return languageMap[ext] ?? "plaintext"; +} + +interface FileViewerPaneProps { + paneId: string; + path: MosaicBranch[]; + pane: Pane; + isActive: boolean; + tabId: string; + worktreePath: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; +} + +export function FileViewerPane({ + paneId, + path, + pane, + isActive, + tabId, + worktreePath, + splitPaneAuto, + removePane, + setFocusedPane, +}: FileViewerPaneProps) { + const containerRef = useRef(null); + const [splitOrientation, setSplitOrientation] = + useState("vertical"); + const isMonacoReady = useMonacoReady(); + const editorRef = useRef(null); + const [isDirty, setIsDirty] = useState(false); + const originalContentRef = useRef(""); + const utils = trpc.useUtils(); + + // Track container dimensions for auto-split orientation + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateOrientation = () => { + const { width, height } = container.getBoundingClientRect(); + setSplitOrientation(width >= height ? "vertical" : "horizontal"); + }; + + updateOrientation(); + + const resizeObserver = new ResizeObserver(updateOrientation); + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + const fileViewer = pane.fileViewer; + + // Extract values with defaults for hooks (hooks must be called unconditionally) + const filePath = fileViewer?.filePath ?? ""; + const viewMode = fileViewer?.viewMode ?? "raw"; + const isLocked = fileViewer?.isLocked ?? false; + const diffCategory = fileViewer?.diffCategory; + const commitHash = fileViewer?.commitHash; + const oldPath = fileViewer?.oldPath; + + // Save mutation + const saveFileMutation = trpc.changes.saveFile.useMutation({ + onSuccess: () => { + setIsDirty(false); + // Update original content to current content after save + if (editorRef.current) { + originalContentRef.current = editorRef.current.getValue(); + } + // Invalidate queries to refresh data + utils.changes.readWorkingFile.invalidate(); + utils.changes.getFileContents.invalidate(); + utils.changes.getStatus.invalidate(); + }, + }); + + // Save handler for raw mode editor + const handleSaveRaw = useCallback(() => { + if (!editorRef.current || !filePath) return; + saveFileMutation.mutate({ + worktreePath, + filePath, + content: editorRef.current.getValue(), + }); + }, [worktreePath, filePath, saveFileMutation]); + + // Save handler for diff mode + const handleSaveDiff = useCallback( + (content: string) => { + if (!filePath) return; + saveFileMutation.mutate({ + worktreePath, + filePath, + content, + }); + }, + [worktreePath, filePath, saveFileMutation], + ); + + // Editor mount handler - set up Cmd+S keybinding + const handleEditorMount: OnMount = useCallback( + (editor) => { + editorRef.current = editor; + // Store original content for dirty tracking + originalContentRef.current = editor.getValue(); + + // Register save action with Cmd+S / Ctrl+S + editor.addAction({ + id: "save-file", + label: "Save File", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: () => { + handleSaveRaw(); + }, + }); + }, + [handleSaveRaw], + ); + + // Track content changes for dirty state + const handleEditorChange = useCallback((value: string | undefined) => { + if (value !== undefined) { + setIsDirty(value !== originalContentRef.current); + } + }, []); + + // Reset dirty state when file changes + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only + useEffect(() => { + setIsDirty(false); + originalContentRef.current = ""; + }, [filePath]); + + // Fetch raw file content - always call hook, use enabled to control fetching + const { data: rawFileData, isLoading: isLoadingRaw } = + trpc.changes.readWorkingFile.useQuery( + { worktreePath, filePath }, + { enabled: !!fileViewer && viewMode !== "diff" && !!filePath }, + ); + + // Fetch diff content - always call hook, use enabled to control fetching + const { data: diffData, isLoading: isLoadingDiff } = + trpc.changes.getFileContents.useQuery( + { + worktreePath, + filePath, + oldPath, + category: diffCategory ?? "unstaged", + commitHash, + }, + { + enabled: + !!fileViewer && viewMode === "diff" && !!diffCategory && !!filePath, + }, + ); + + // Early return AFTER hooks + if (!fileViewer) { + return ( + path={path} title=""> +
+ No file viewer state +
+ + ); + } + + const handleFocus = () => { + setFocusedPane(tabId, paneId); + }; + + const handleClosePane = (e: React.MouseEvent) => { + e.stopPropagation(); + removePane(paneId); + }; + + const handleSplitPane = (e: React.MouseEvent) => { + e.stopPropagation(); + const container = containerRef.current; + if (!container) return; + + const { width, height } = container.getBoundingClientRect(); + splitPaneAuto(tabId, paneId, { width, height }, path); + }; + + const handleToggleLock = () => { + // Update the pane's lock state in the store + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + isLocked: !currentPane.fileViewer.isLocked, + }, + }, + }, + }); + } + }; + + const handleViewModeChange = (value: string) => { + if (!value) return; + const newMode = value as FileViewerMode; + + // Update the pane's view mode in the store + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + viewMode: newMode, + }, + }, + }, + }); + } + }; + + const fileName = filePath.split("/").pop() || filePath; + + // Render content based on view mode + const renderContent = () => { + if (viewMode === "diff") { + if (isLoadingDiff) { + return ( +
+ Loading diff... +
+ ); + } + if (!diffData) { + return ( +
+ No diff available +
+ ); + } + return ( + + ); + } + + if (isLoadingRaw) { + return ( +
+ Loading... +
+ ); + } + + if (!rawFileData?.ok) { + const errorMessage = + rawFileData?.reason === "too-large" + ? "File is too large to preview" + : rawFileData?.reason === "binary" + ? "Binary file preview not supported" + : rawFileData?.reason === "outside-worktree" + ? "File is outside worktree" + : "File not found"; + return ( +
+ {errorMessage} +
+ ); + } + + if (viewMode === "rendered") { + return ( +
+ +
+ ); + } + + // Raw mode - editable Monaco editor + if (!isMonacoReady) { + return ( +
+ + Loading editor... +
+ ); + } + + return ( + + + Loading editor... + + } + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "on", + fontSize: 13, + lineHeight: 20, + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace", + padding: { top: 8, bottom: 8 }, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + }} + /> + ); + }; + + // Determine which view modes are available + const isMarkdown = filePath.endsWith(".md") || filePath.endsWith(".markdown"); + const hasDiff = !!diffCategory; + + const splitIcon = + splitOrientation === "vertical" ? ( + + ) : ( + + ); + + // Show editable badge for raw and diff modes (not rendered) + const showEditableBadge = viewMode !== "rendered"; + const isSaving = saveFileMutation.isPending; + + return ( + + path={path} + title="" + renderToolbar={() => ( +
+
+ + {isDirty && } + {fileName} + + {showEditableBadge && ( + + + {isSaving ? "Saving..." : "⌘S"} + + )} +
+
+ + {isMarkdown && ( + + Rendered + + )} + + Raw + + {hasDiff && ( + + Diff + + )} + + + + + + + Split pane + + + + + + + + {isLocked + ? "Unlock (allow file replacement)" + : "Lock (prevent file replacement)"} + + + + + + + + Close + + +
+
+ )} + className={isActive ? "mosaic-window-focused" : ""} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Focus handler */} +
+ {renderContent()} +
+ + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts new file mode 100644 index 00000000000..96c33fa0b12 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts @@ -0,0 +1 @@ +export { FileViewerPane } from "./FileViewerPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index b6df3608e13..3502a82dce2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -8,6 +8,7 @@ import { type MosaicNode, } from "react-mosaic-component"; import { dragDropManager } from "renderer/lib/dnd"; +import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Pane, Tab } from "renderer/stores/tabs/types"; import { @@ -15,6 +16,7 @@ import { extractPaneIdsFromLayout, getPaneIdsForTab, } from "renderer/stores/tabs/utils"; +import { FileViewerPane } from "./FileViewerPane"; import { TabPane } from "./TabPane"; interface TabViewProps { @@ -35,6 +37,10 @@ export function TabView({ tab, panes }: TabViewProps) { const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const allTabs = useTabsStore((s) => s.tabs); + // Get worktree path for file viewer panes + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const worktreePath = activeWorkspace?.worktreePath ?? ""; + // Get tabs in the same workspace for move targets const workspaceTabs = allTabs.filter( (t) => t.workspaceId === tab.workspaceId, @@ -90,6 +96,24 @@ export function TabView({ tab, panes }: TabViewProps) { ); } + // Route file-viewer panes to FileViewerPane component + if (pane.type === "file-viewer") { + return ( + + ); + } + + // Default: terminal panes return ( ; } - return ; + return ( +
+ +
+ +
+
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 1a2e20bd0fb..835e914fc05 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,11 +1,22 @@ -import { SidebarMode, useSidebarStore } from "renderer/stores"; +import { trpc } from "renderer/lib/trpc"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import { ChangesContent } from "./ChangesContent"; import { TabsContent } from "./TabsContent"; export function ContentView() { - const { currentMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - if (currentMode === SidebarMode.Changes) { + // Subscribe to the actual data, not just the getter function + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + + const viewMode = workspaceId + ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") + : "workbench"; + + if (viewMode === "review") { return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx index ee0ec6d2e9c..b269533cc7f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -6,13 +6,22 @@ import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { PortsList } from "../TabsView/PortsList"; import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { CommitItem } from "./components/CommitItem"; import { FileList } from "./components/FileList"; -export function ChangesView() { +interface ChangesViewProps { + onFileOpen?: ( + file: ChangedFile, + category: ChangeCategory, + commitHash?: string, + ) => void; +} + +export function ChangesView({ onFileOpen }: ChangesViewProps) { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const worktreePath = activeWorkspace?.worktreePath; @@ -128,11 +137,13 @@ export function ChangesView() { const handleFileSelect = (file: ChangedFile, category: ChangeCategory) => { if (!worktreePath) return; selectFile(worktreePath, file, category, null); + onFileOpen?.(file, category); }; const handleCommitFileSelect = (file: ChangedFile, commitHash: string) => { if (!worktreePath) return; selectFile(worktreePath, file, "committed", commitHash); + onFileOpen?.(file, "committed", commitHash); }; const handleCommitToggle = (hash: string) => { @@ -349,6 +360,8 @@ export function ChangesView() {
)} + +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 7511587610a..b93ef1f3849 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -1,29 +1,40 @@ -import { useSidebarStore } from "renderer/stores"; -import { SidebarMode } from "renderer/stores/sidebar-state"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { ChangesView } from "./ChangesView"; -import { ModeCarousel } from "./ModeCarousel"; -import { TabsView } from "./TabsView"; export function Sidebar() { - const { currentMode, setMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + // Subscribe to the actual data, not just the getter function + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + + const viewMode = workspaceId + ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") + : "workbench"; + + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + + // In Workbench mode, open files in FileViewerPane + const handleFileOpen = + viewMode === "workbench" && workspaceId + ? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + addFileViewerPane(workspaceId, { + filePath: file.path, + diffCategory: category, + commitHash, + oldPath: file.oldPath, + }); + } + : undefined; return ( ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx index f641fde5af1..526bca55e10 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx @@ -1,3 +1,4 @@ +import { ViewModeToggle } from "./components/ViewModeToggle"; import { WorkspaceActionBarLeft } from "./components/WorkspaceActionBarLeft"; import { WorkspaceActionBarRight } from "./components/WorkspaceActionBarRight"; @@ -9,11 +10,14 @@ export function WorkspaceActionBar({ worktreePath }: WorkspaceActionBarProps) { if (!worktreePath) return null; return ( -
+
-
+
+ +
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx new file mode 100644 index 00000000000..bce7ecd1289 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx @@ -0,0 +1,55 @@ +import { cn } from "@superset/ui/utils"; +import { trpc } from "renderer/lib/trpc"; +import { + useWorkspaceViewModeStore, + type WorkspaceViewMode, +} from "renderer/stores/workspace-view-mode"; + +export function ViewModeToggle() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; + + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + const setWorkspaceViewMode = useWorkspaceViewModeStore( + (s) => s.setWorkspaceViewMode, + ); + + if (!workspaceId) return null; + + const currentMode = viewModeByWorkspaceId[workspaceId] ?? "workbench"; + + const handleModeChange = (mode: WorkspaceViewMode) => { + setWorkspaceViewMode(workspaceId, mode); + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts new file mode 100644 index 00000000000..5e69ac17ef8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts @@ -0,0 +1 @@ +export { ViewModeToggle } from "./ViewModeToggle"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 661543c8580..a1d61bf3e53 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -3,6 +3,7 @@ import { trpc } from "renderer/lib/trpc"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; import { WorkspaceActionBar } from "./WorkspaceActionBar"; @@ -39,16 +40,31 @@ export function WorkspaceView() { // Get focused pane ID for the active tab const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; + // View mode for terminal creation - subscribe to actual data for reactivity + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + const setWorkspaceViewMode = useWorkspaceViewModeStore( + (s) => s.setWorkspaceViewMode, + ); + const viewMode = activeWorkspaceId + ? (viewModeByWorkspaceId[activeWorkspaceId] ?? "workbench") + : "workbench"; + // Tab management shortcuts useAppHotkey( "NEW_TERMINAL", () => { if (activeWorkspaceId) { + // If in Review mode, switch to Workbench first + if (viewMode === "review") { + setWorkspaceViewMode(activeWorkspaceId, "workbench"); + } addTab(activeWorkspaceId); } }, undefined, - [activeWorkspaceId, addTab], + [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode], ); useAppHotkey( diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index b289f0bfb38..886d0523e00 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -6,3 +6,4 @@ export * from "./ringtone"; export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; +export * from "./workspace-view-mode"; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index d28d8316add..b139bf235fd 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,9 +4,10 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { trpcTabsStorage } from "../../lib/trpc-storage"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; -import type { TabsState, TabsStore } from "./types"; +import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, + createFileViewerPane, createPane, createTabWithPane, extractPaneIdsFromLayout, @@ -340,6 +341,112 @@ export const useTabsStore = create()( return newPane.id; }, + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => { + const state = get(); + const activeTabId = state.activeTabIds[workspaceId]; + const activeTab = state.tabs.find((t) => t.id === activeTabId); + + // If no active tab, create a new one (this shouldn't normally happen) + if (!activeTab) { + const { tabId, paneId } = get().addTab(workspaceId); + // Update the pane to be a file-viewer + const pane = state.panes[paneId]; + if (pane) { + const fileViewerPane = createFileViewerPane(tabId, options); + set((s) => ({ + panes: { + ...s.panes, + [paneId]: { + ...fileViewerPane, + id: paneId, // Keep the original ID + }, + }, + })); + } + return paneId; + } + + // Look for an existing unlocked file-viewer pane in the active tab + const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); + const fileViewerPanes = tabPaneIds + .map((id) => state.panes[id]) + .filter( + (p) => + p?.type === "file-viewer" && + p.fileViewer && + !p.fileViewer.isLocked, + ); + + // If we found an unlocked file-viewer pane, reuse it + if (fileViewerPanes.length > 0) { + const paneToReuse = fileViewerPanes[0]; + const fileName = + options.filePath.split("/").pop() || options.filePath; + + // Determine default view mode + let viewMode: "raw" | "rendered" | "diff" = "raw"; + if (options.diffCategory) { + viewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") + ) { + viewMode = "rendered"; + } + + set({ + panes: { + ...state.panes, + [paneToReuse.id]: { + ...paneToReuse, + name: fileName, + fileViewer: { + filePath: options.filePath, + viewMode, + isLocked: false, + diffLayout: "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + }, + }, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: paneToReuse.id, + }, + }); + + return paneToReuse.id; + } + + // No reusable pane found, create a new one + const newPane = createFileViewerPane(activeTab.id, options); + + const newLayout: MosaicNode = { + direction: "row", + first: activeTab.layout, + second: newPane.id, + splitPercentage: 50, + }; + + set({ + tabs: state.tabs.map((t) => + t.id === activeTab.id ? { ...t, layout: newLayout } : t, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: newPane.id, + }, + }); + + return newPane.id; + }, + removePane: (paneId) => { const state = get(); const pane = state.panes[paneId]; diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index bcb0f70af82..03fc45d921b 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,4 +1,5 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; // Re-export shared types @@ -28,6 +29,16 @@ export interface AddTabOptions { initialCwd?: string; } +/** + * Options for opening a file in a file-viewer pane + */ +export interface AddFileViewerPaneOptions { + filePath: string; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; +} + /** * Actions available on the tabs store */ @@ -51,6 +62,10 @@ export interface TabsStore extends TabsState { // Pane operations addPane: (tabId: string, options?: AddTabOptions) => string; + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => string; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index a1e7bef16cf..207ac8295a7 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,4 +1,10 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; +import type { + DiffLayout, + FileViewerMode, + FileViewerState, +} from "shared/tabs-types"; import type { Pane, PaneType, Tab } from "./types"; /** @@ -82,6 +88,61 @@ export const createPane = ( }; }; +/** + * Options for creating a file-viewer pane + */ +export interface CreateFileViewerPaneOptions { + filePath: string; + viewMode?: FileViewerMode; + isLocked?: boolean; + diffLayout?: DiffLayout; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; +} + +/** + * Creates a new file-viewer pane with the given properties + */ +export const createFileViewerPane = ( + tabId: string, + options: CreateFileViewerPaneOptions, +): Pane => { + const id = generateId("pane"); + + // Determine default view mode based on file and category + let defaultViewMode: FileViewerMode = "raw"; + if (options.diffCategory) { + defaultViewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") + ) { + defaultViewMode = "rendered"; + } + + const fileViewer: FileViewerState = { + filePath: options.filePath, + viewMode: options.viewMode ?? defaultViewMode, + isLocked: options.isLocked ?? false, + diffLayout: options.diffLayout ?? "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + }; + + // Use filename for display name + const fileName = options.filePath.split("/").pop() || options.filePath; + + return { + id, + tabId, + type: "file-viewer", + name: fileName, + fileViewer, + }; +}; + /** * Generates a static tab name based on existing tabs * (e.g., "Terminal 1", "Terminal 2", finding the next available number) diff --git a/apps/desktop/src/renderer/stores/workspace-view-mode.ts b/apps/desktop/src/renderer/stores/workspace-view-mode.ts new file mode 100644 index 00000000000..b9bee9b2665 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-view-mode.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +/** + * Workspace view modes: + * - "workbench": Groups + Mosaic panes layout for in-flow work + * - "review": Dedicated Changes page for focused code review + */ +export type WorkspaceViewMode = "workbench" | "review"; + +interface WorkspaceViewModeState { + /** + * Per-workspace view mode. Defaults to "workbench" when not set. + */ + viewModeByWorkspaceId: Record; + + /** + * Get the view mode for a workspace, defaulting to "workbench" + */ + getWorkspaceViewMode: (workspaceId: string) => WorkspaceViewMode; + + /** + * Set the view mode for a workspace + */ + setWorkspaceViewMode: (workspaceId: string, mode: WorkspaceViewMode) => void; +} + +export const useWorkspaceViewModeStore = create()( + devtools( + persist( + (set, get) => ({ + viewModeByWorkspaceId: {}, + + getWorkspaceViewMode: (workspaceId: string) => { + return get().viewModeByWorkspaceId[workspaceId] ?? "workbench"; + }, + + setWorkspaceViewMode: ( + workspaceId: string, + mode: WorkspaceViewMode, + ) => { + set((state) => ({ + viewModeByWorkspaceId: { + ...state.viewModeByWorkspaceId, + [workspaceId]: mode, + }, + })); + }, + }), + { + name: "workspace-view-mode-store", + }, + ), + { name: "WorkspaceViewModeStore" }, + ), +); diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 8ae323601eb..d38ce4c4284 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -3,10 +3,42 @@ * Renderer extends these with MosaicNode layout specifics. */ +import type { ChangeCategory } from "./changes-types"; + /** * Pane types that can be displayed within a tab */ -export type PaneType = "terminal" | "webview"; +export type PaneType = "terminal" | "webview" | "file-viewer"; + +/** + * File viewer display modes + */ +export type FileViewerMode = "rendered" | "raw" | "diff"; + +/** + * Diff layout options for file viewer + */ +export type DiffLayout = "inline" | "side-by-side"; + +/** + * File viewer pane-specific properties + */ +export interface FileViewerState { + /** Worktree-relative file path */ + filePath: string; + /** Display mode: rendered (markdown), raw (source), or diff */ + viewMode: FileViewerMode; + /** If true, this pane won't be reused for new file clicks */ + isLocked: boolean; + /** Diff display layout */ + diffLayout: DiffLayout; + /** Category for diff source (against-main, committed, staged, unstaged) */ + diffCategory?: ChangeCategory; + /** Commit hash for committed category diffs */ + commitHash?: string; + /** Original path for renamed files */ + oldPath?: string; +} /** * Base Pane interface - shared between main and renderer @@ -23,6 +55,7 @@ export interface Pane { url?: string; // For webview panes cwd?: string | null; // Current working directory cwdConfirmed?: boolean; // True if cwd confirmed via OSC-7, false if seeded + fileViewer?: FileViewerState; // For file-viewer panes } /** From 8287a78c71d0041d0890620c43ff59f0d7829978 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 29 Dec 2025 18:32:50 +0200 Subject: [PATCH 02/64] fix(desktop): add worktreePath guards and fix stale state in tabs store - Add !!worktreePath checks to FileViewerPane query enabled conditions - Add !!worktreePath checks to save handler guards (handleSaveRaw, handleSaveDiff) - Fix stale state reference after addTab() in addFileViewerPane action Addresses CodeRabbit review feedback. --- .../TabView/FileViewerPane/FileViewerPane.tsx | 15 ++++++++---- .../desktop/src/renderer/stores/tabs/store.ts | 23 ++++++++----------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index a9dac12d7a5..a607ed7b184 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -152,7 +152,7 @@ export function FileViewerPane({ // Save handler for raw mode editor const handleSaveRaw = useCallback(() => { - if (!editorRef.current || !filePath) return; + if (!editorRef.current || !filePath || !worktreePath) return; saveFileMutation.mutate({ worktreePath, filePath, @@ -163,7 +163,7 @@ export function FileViewerPane({ // Save handler for diff mode const handleSaveDiff = useCallback( (content: string) => { - if (!filePath) return; + if (!filePath || !worktreePath) return; saveFileMutation.mutate({ worktreePath, filePath, @@ -211,7 +211,10 @@ export function FileViewerPane({ const { data: rawFileData, isLoading: isLoadingRaw } = trpc.changes.readWorkingFile.useQuery( { worktreePath, filePath }, - { enabled: !!fileViewer && viewMode !== "diff" && !!filePath }, + { + enabled: + !!fileViewer && viewMode !== "diff" && !!filePath && !!worktreePath, + }, ); // Fetch diff content - always call hook, use enabled to control fetching @@ -226,7 +229,11 @@ export function FileViewerPane({ }, { enabled: - !!fileViewer && viewMode === "diff" && !!diffCategory && !!filePath, + !!fileViewer && + viewMode === "diff" && + !!diffCategory && + !!filePath && + !!worktreePath, }, ); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index b139bf235fd..ee1943fe7a7 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -352,20 +352,17 @@ export const useTabsStore = create()( // If no active tab, create a new one (this shouldn't normally happen) if (!activeTab) { const { tabId, paneId } = get().addTab(workspaceId); - // Update the pane to be a file-viewer - const pane = state.panes[paneId]; - if (pane) { - const fileViewerPane = createFileViewerPane(tabId, options); - set((s) => ({ - panes: { - ...s.panes, - [paneId]: { - ...fileViewerPane, - id: paneId, // Keep the original ID - }, + // Update the pane to be a file-viewer (must use set() to get fresh state after addTab) + const fileViewerPane = createFileViewerPane(tabId, options); + set((s) => ({ + panes: { + ...s.panes, + [paneId]: { + ...fileViewerPane, + id: paneId, // Keep the original ID }, - })); - } + }, + })); return paneId; } From 8efd8362b12878071cbaa8a7c318444d6063cc7a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 29 Dec 2025 18:50:33 +0200 Subject: [PATCH 03/64] fix(desktop): address CodeRabbit review - path traversal security fix and UX improvements - Add validatePathForWrite() to prevent path traversal attacks in saveFile - Add aria-pressed attribute to ViewModeToggle buttons for accessibility - Increase close button touch target in GroupStrip for better UX - Add .mdx file support for rendered view mode --- .../lib/trpc/routers/changes/file-contents.ts | 62 ++++++++++++++++++- .../TabsContent/GroupStrip/GroupStrip.tsx | 4 +- .../ViewModeToggle/ViewModeToggle.tsx | 2 + .../desktop/src/renderer/stores/tabs/store.ts | 3 +- .../desktop/src/renderer/stores/tabs/utils.ts | 3 +- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 29f035a058f..262a03797f1 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -23,7 +23,8 @@ type ReadWorkingFileResult = }; /** - * Validates that a file path is within the worktree and doesn't escape via symlinks + * Validates that a file path is within the worktree and doesn't escape via symlinks. + * Requires the file to exist (uses realpath). */ async function validatePathInWorktree( worktreePath: string, @@ -60,6 +61,48 @@ async function validatePathInWorktree( } } +/** + * Validates that a file path is safe for writing within the worktree. + * Does not require the file to exist (validates path structure and parent directory). + */ +async function validatePathForWrite( + worktreePath: string, + filePath: string, +): Promise<{ valid: boolean; resolvedPath?: string; reason?: string }> { + // Reject absolute paths + if (isAbsolute(filePath)) { + return { valid: false, reason: "outside-worktree" }; + } + + // Normalize and check for traversal + const normalizedPath = normalize(filePath); + if (normalizedPath.startsWith("..") || normalizedPath.includes("/../")) { + return { valid: false, reason: "outside-worktree" }; + } + + const fullPath = join(worktreePath, normalizedPath); + + // Resolve the worktree path and verify our target path is within it + try { + const realWorktreePath = await realpath(worktreePath); + + // For writes, we can't realpath the file (it may not exist), but we can check + // the normalized path structure is within the worktree + const candidatePath = join(realWorktreePath, normalizedPath); + const relativePath = relative(realWorktreePath, candidatePath); + + // If relative path starts with "..", the file is outside worktree + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + return { valid: false, reason: "outside-worktree" }; + } + + return { valid: true, resolvedPath: candidatePath }; + } catch { + // Worktree path doesn't exist or isn't accessible + return { valid: false, reason: "not-found" }; + } +} + /** * Detects if a buffer contains binary content by checking for NUL bytes */ @@ -117,8 +160,21 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await writeFile(fullPath, input.content, "utf-8"); + // Validate path is within worktree (prevents path traversal attacks) + const validation = await validatePathForWrite( + input.worktreePath, + input.filePath, + ); + + if (!validation.valid || !validation.resolvedPath) { + throw new Error( + validation.reason === "outside-worktree" + ? "Cannot write to files outside worktree" + : "File path validation failed", + ); + } + + await writeFile(validation.resolvedPath, input.content, "utf-8"); return { success: true }; }), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index e4aa4e47352..162114e9687 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -55,9 +55,9 @@ function GroupItem({ e.stopPropagation(); onClose(); }} - className="absolute -right-1 -top-1 p-0.5 rounded-full bg-muted opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive hover:text-destructive-foreground" + className="absolute -right-1.5 -top-1.5 p-1 rounded-full bg-muted opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive hover:text-destructive-foreground" > - +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx index bce7ecd1289..00b93eaf6dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx @@ -29,6 +29,7 @@ export function ViewModeToggle() {
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 20a261a4d85..4c67aba85b0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -5,6 +5,7 @@ import "@xterm/xterm/css/xterm.css"; import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; +import { trpcClient } from "renderer/lib/trpc-client"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; @@ -47,6 +48,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); const terminalTheme = useTerminalTheme(); // Ref for initial theme to avoid recreating terminal on theme change @@ -68,6 +70,41 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const { data: workspaceCwd } = trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + // Query terminal link behavior setting + const { data: terminalLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + // Handler for file link clicks - uses current setting value + const handleFileLinkClick = useCallback( + (path: string, line?: number, column?: number) => { + const behavior = terminalLinkBehavior ?? "external-editor"; + + if (behavior === "file-viewer") { + addFileViewerPane(workspaceId, { filePath: path }); + } else { + trpcClient.external.openFileInEditor + .mutate({ + path, + line, + column, + cwd: workspaceCwd ?? undefined, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + }); + } + }, + [terminalLinkBehavior, workspaceId, workspaceCwd, addFileViewerPane], + ); + + // Ref to avoid terminal recreation when callback changes + const handleFileLinkClickRef = useRef(handleFileLinkClick); + handleFileLinkClickRef.current = handleFileLinkClick; + // Seed cwd from initialCwd or workspace path (shell spawns there) // OSC-7 will override if/when the shell reports directory changes useEffect(() => { @@ -197,11 +234,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, cleanup: cleanupQuerySuppression, - } = createTerminalInstance( - container, - workspaceCwd, - initialThemeRef.current, - ); + } = createTerminalInstance(container, { + cwd: workspaceCwd, + initialTheme: initialThemeRef.current, + onFileLinkClick: (path, line, column) => + handleFileLinkClickRef.current(path, line, column), + }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; isExitedRef.current = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 3c92f907dd2..acc9eeb7a2e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -93,19 +93,26 @@ function loadRenderer(xterm: XTerm): { dispose: () => void } { }; } +export interface CreateTerminalOptions { + cwd?: string; + initialTheme?: ITheme | null; + onFileLinkClick?: (path: string, line?: number, column?: number) => void; +} + export function createTerminalInstance( container: HTMLDivElement, - cwd?: string, - initialTheme?: ITheme | null, + options: CreateTerminalOptions = {}, ): { xterm: XTerm; fitAddon: FitAddon; cleanup: () => void; } { + const { cwd, initialTheme, onFileLinkClick } = options; + // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); - const options = { ...TERMINAL_OPTIONS, theme }; - const xterm = new XTerm(options); + const terminalOptions = { ...TERMINAL_OPTIONS, theme }; + const xterm = new XTerm(terminalOptions); const fitAddon = new FitAddon(); const clipboardAddon = new ClipboardAddon(); @@ -149,20 +156,25 @@ export function createTerminalInstance( const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { - trpcClient.external.openFileInEditor - .mutate({ - path, - line, - column, - cwd, - }) - .catch((error) => { - console.error( - "[Terminal] Failed to open file in editor:", + if (onFileLinkClick) { + onFileLinkClick(path, line, column); + } else { + // Fallback to default behavior (external editor) + trpcClient.external.openFileInEditor + .mutate({ path, - error, - ); - }); + line, + column, + cwd, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + }); + } }, ); xterm.registerLinkProvider(filePathLinkProvider); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 6bc788cead4..1ec904ae1fc 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -46,3 +46,4 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; +export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; diff --git a/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql b/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql new file mode 100644 index 00000000000..ad70f21f3fe --- /dev/null +++ b/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `terminal_link_behavior` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0004_snapshot.json b/packages/local-db/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000000..991b5469eb5 --- /dev/null +++ b/packages/local-db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,977 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bb9f9f85-bcbb-4003-b20f-4c172a1c6fc8", + "prevId": "d5a52ac9-bc1e-4529-89bf-5748d4df5006", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 3117a6e2266..65dc59e762b 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1766932805546, "tag": "0003_add_confirm_on_quit_setting", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1767166138761, + "tag": "0004_add_terminal_link_behavior_setting", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 9b0639805f0..0f98d17ada9 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -5,6 +5,7 @@ import type { ExternalApp, GitHubStatus, GitStatus, + TerminalLinkBehavior, TerminalPreset, WorkspaceType, } from "./zod"; @@ -127,6 +128,9 @@ export const settings = sqliteTable("settings", { selectedRingtoneId: text("selected_ringtone_id"), activeOrganizationId: text("active_organization_id"), confirmOnQuit: integer("confirm_on_quit", { mode: "boolean" }), + terminalLinkBehavior: text( + "terminal_link_behavior", + ).$type(), }); export type InsertSettings = typeof settings.$inferInsert; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index bb33e1d1596..ca8221e405d 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -96,3 +96,13 @@ export const EXTERNAL_APPS = [ ] as const; export type ExternalApp = (typeof EXTERNAL_APPS)[number]; + +/** + * Terminal link behavior options + */ +export const TERMINAL_LINK_BEHAVIORS = [ + "external-editor", + "file-viewer", +] as const; + +export type TerminalLinkBehavior = (typeof TERMINAL_LINK_BEHAVIORS)[number]; From d8676b2fb69cbdd286a9fc942dc2ebdd736c7192 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 09:35:50 +0200 Subject: [PATCH 14/64] refactor(desktop): simplify security - remove symlink escape detection Remove async symlink escape detection from path validation since the threat model doesn't justify it: a compromised renderer already has terminal access for arbitrary command execution. Changes: - Replace resolveSecurePath (async) with resolvePathInWorktree (sync) - Remove assertNoSymlinkEscape and checkSymlinks option - Add validateRelativePath for simple path safety checks - Update secure-fs.ts to use new sync functions - Update threat model documentation in path-validation.ts --- .../trpc/routers/changes/security/index.ts | 17 +- .../changes/security/path-validation.ts | 219 ++++++------------ .../routers/changes/security/secure-fs.ts | 62 ++--- 3 files changed, 94 insertions(+), 204 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts index b2763809771..8fdb09c9e7a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -1,13 +1,11 @@ /** * Security module for changes routers. * - * This module provides: - * - Path validation with symlink escape protection - * - Secure filesystem wrappers - * - Worktree registration checks + * Security model: + * - PRIMARY: Worktree must be registered in localDb + * - SECONDARY: Paths validated for traversal attempts * - * All filesystem operations in the changes routers should go through - * this module to ensure consistent security checks. + * See path-validation.ts header for full threat model. */ export { @@ -18,13 +16,16 @@ export { gitUnstageAll, gitUnstageFile, } from "./git-commands"; + export { assertRegisteredWorktree, assertValidGitPath, getRegisteredWorktree, PathValidationError, type PathValidationErrorCode, - type ResolveSecurePathOptions, - resolveSecurePath, + resolvePathInWorktree, + type ValidatePathOptions, + validateRelativePath, } from "./path-validation"; + export { secureFs } from "./secure-fs"; diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts index 675f209f030..72292859b02 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -1,23 +1,40 @@ -import { realpath } from "node:fs/promises"; -import { - dirname, - isAbsolute, - normalize, - relative, - resolve, - sep, -} from "node:path"; +import { isAbsolute, normalize, resolve, sep } from "node:path"; import { worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; +/** + * Security model for desktop app filesystem access: + * + * THREAT MODEL ASSUMPTION: + * A compromised renderer can already execute arbitrary commands via + * terminal panes. Therefore, filesystem-level symlink protections + * provide no meaningful security boundary—an attacker with renderer + * access can simply run `cat /etc/passwd` in a terminal. + * + * If your deployment exposes the renderer to untrusted content WITHOUT + * terminal access, this model does NOT apply and symlink escape checks + * should be re-enabled. + * + * PRIMARY BOUNDARY: assertRegisteredWorktree() + * - Only worktree paths registered in localDb are accessible via tRPC + * - Prevents direct filesystem access to unregistered paths + * + * SECONDARY: validateRelativePath() + * - Rejects absolute paths and ".." traversal segments + * - Defense in depth against path manipulation + * + * NOT IMPLEMENTED (intentional, see threat model above): + * - Symlink escape detection + * - Realpath resolution + */ + /** * Security error codes for path validation failures. */ export type PathValidationErrorCode = | "ABSOLUTE_PATH" | "PATH_TRAVERSAL" - | "SYMLINK_ESCAPE" | "UNREGISTERED_WORKTREE" | "INVALID_TARGET"; @@ -37,9 +54,7 @@ export class PathValidationError extends Error { /** * Validates that a worktree path is registered in localDb. - * - * This is THE critical security boundary - prevents arbitrary filesystem access. - * A compromised renderer cannot access files outside registered worktrees. + * This is THE critical security boundary. * * @throws PathValidationError if worktree is not registered */ @@ -59,8 +74,7 @@ export function assertRegisteredWorktree(worktreePath: string): void { } /** - * Gets the worktree record if it exists in localDb. - * Returns the record for additional operations (e.g., updating branch). + * Gets the worktree record if registered. Returns record for updates. * * @throws PathValidationError if worktree is not registered */ @@ -84,86 +98,9 @@ export function getRegisteredWorktree( } /** - * Checks if a relative path escapes its parent directory. - * - * Uses the correct segment-aware check: - * - `..` alone escapes - * - `../anything` escapes - * - `..foo` does NOT escape (legitimate directory name) - */ -function escapesParent(relativePath: string): boolean { - return ( - relativePath === ".." || - relativePath.startsWith(`..${sep}`) || - isAbsolute(relativePath) - ); -} - -/** - * Validates a file path doesn't escape the worktree via symlinks. - * - * Handles new files by walking up to find the first existing ancestor - * and validating that ancestor is within the worktree. - * - * @throws PathValidationError if symlink escape detected or validation fails + * Options for path validation. */ -async function assertNoSymlinkEscape( - worktreePath: string, - fullPath: string, -): Promise { - const realWorktree = await realpath(worktreePath); - - // Walk up to find first existing ancestor - let checkPath = fullPath; - const root = resolve("/"); - - while (checkPath !== root) { - try { - const realPath = await realpath(checkPath); - const rel = relative(realWorktree, realPath); - - if (escapesParent(rel)) { - throw new PathValidationError( - "Path escapes worktree via symlink", - "SYMLINK_ESCAPE", - ); - } - - // Found existing path and validated it - we're done - return; - } catch (e) { - if (e instanceof PathValidationError) { - throw e; - } - - if ((e as NodeJS.ErrnoException).code === "ENOENT") { - // Path doesn't exist, check parent - const parent = dirname(checkPath); - if (parent === checkPath) { - // Hit filesystem root without finding existing path - // This shouldn't happen for valid worktree paths - break; - } - checkPath = parent; - continue; - } - - // For any other error (permissions, etc.), fail closed - throw new PathValidationError( - `Cannot verify path security: ${(e as Error).message}`, - "SYMLINK_ESCAPE", - ); - } - } -} - -export interface ResolveSecurePathOptions { - /** - * Check for symlink escapes. Required for destructive operations. - * Default: true (fail closed) - */ - checkSymlinks?: boolean; - +export interface ValidatePathOptions { /** * Allow empty/root path (resolves to worktree itself). * Default: false (prevents accidental worktree deletion) @@ -172,31 +109,18 @@ export interface ResolveSecurePathOptions { } /** - * Validates and resolves a file path within a worktree. - * - * Security checks: - * 1. Rejects absolute paths - * 2. Rejects path traversal via `..` segments - * 3. Rejects symlink escapes (by default) - * 4. Rejects root path unless explicitly allowed + * Validates a relative file path for safety. + * Rejects absolute paths and path traversal attempts. * - * Uses `path.relative()` containment check - the industry standard pattern - * from VSCode, MCP servers, and security-focused libraries. - * - * @param worktreePath - The registered worktree base path - * @param filePath - The relative file path to validate - * @param options - Validation options - * @returns The resolved full path - * @throws PathValidationError on any validation failure + * @throws PathValidationError if path is invalid */ -export async function resolveSecurePath( - worktreePath: string, +export function validateRelativePath( filePath: string, - options: ResolveSecurePathOptions = {}, -): Promise { - const { checkSymlinks = true, allowRoot = false } = options; + options: ValidatePathOptions = {}, +): void { + const { allowRoot = false } = options; - // 1. Reject absolute paths immediately + // Reject absolute paths if (isAbsolute(filePath)) { throw new PathValidationError( "Absolute paths are not allowed", @@ -204,61 +128,50 @@ export async function resolveSecurePath( ); } - // 2. Normalize and resolve const normalized = normalize(filePath); - const fullPath = resolve(worktreePath, normalized); - - // 3. Containment check via relative path - const relativePath = relative(worktreePath, fullPath); + const segments = normalized.split(sep); - if (escapesParent(relativePath)) { + // Reject ".." as a path segment (allows "..foo" directories) + if (segments.includes("..")) { throw new PathValidationError( - "Path escapes worktree boundary", + "Path traversal not allowed", "PATH_TRAVERSAL", ); } - // 4. Check for root path (empty or ".") - if (!allowRoot && (relativePath === "" || relativePath === ".")) { + // Reject root path unless explicitly allowed + if (!allowRoot && (normalized === "" || normalized === ".")) { throw new PathValidationError( "Cannot target worktree root", "INVALID_TARGET", ); } - - // 5. Symlink escape check (default: enabled for safety) - if (checkSymlinks) { - await assertNoSymlinkEscape(worktreePath, fullPath); - } - - return fullPath; } /** - * Validates a path for use in git commands (pathspec). + * Validates and resolves a path within a worktree. Sync, simple. * - * Lighter validation than resolveSecurePath - just checks for - * obvious escapes. Git itself provides additional sandboxing. + * @param worktreePath - The worktree base path + * @param filePath - The relative file path to validate + * @param options - Validation options + * @returns The resolved full path + * @throws PathValidationError if path is invalid + */ +export function resolvePathInWorktree( + worktreePath: string, + filePath: string, + options: ValidatePathOptions = {}, +): string { + validateRelativePath(filePath, options); + // Use resolve to handle any worktreePath (relative or absolute) + return resolve(worktreePath, normalize(filePath)); +} + +/** + * Validates a path for git commands. Lighter check that allows root. * - * @param filePath - The file path to validate - * @throws PathValidationError if path is suspicious + * @throws PathValidationError if path is invalid */ export function assertValidGitPath(filePath: string): void { - if (isAbsolute(filePath)) { - throw new PathValidationError( - "Absolute paths are not allowed in git operations", - "ABSOLUTE_PATH", - ); - } - - const normalized = normalize(filePath); - const segments = normalized.split(sep); - - // Check for ".." as a segment (not substring - allows "..foo") - if (segments.includes("..")) { - throw new PathValidationError( - "Path traversal not allowed in git operations", - "PATH_TRAVERSAL", - ); - } + validateRelativePath(filePath, { allowRoot: true }); } diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 1f4bb0e3d5e..2e461dd731a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -1,27 +1,23 @@ import type { Stats } from "node:fs"; import { lstat, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { assertRegisteredWorktree, resolveSecurePath } from "./path-validation"; +import { + assertRegisteredWorktree, + resolvePathInWorktree, +} from "./path-validation"; /** - * Secure filesystem operations that enforce validation. + * Secure filesystem operations with built-in validation. * - * Design principle: You cannot perform filesystem operations without - * going through validation. The validation is built into each operation. + * Each operation: + * 1. Validates worktree is registered (security boundary) + * 2. Validates path doesn't escape worktree (defense in depth) + * 3. Performs the filesystem operation * - * All operations: - * 1. Validate worktree is registered in database - * 2. Validate path doesn't escape worktree - * 3. Check for symlink escapes (configurable) - * - * Use Biome's restricted-imports rule to ban direct `node:fs` imports - * in router files - this module should be the only FS access point. + * See path-validation.ts for the full security model and threat assumptions. */ export const secureFs = { /** * Read a file within a worktree. - * - * Validates path and checks for symlink escapes to prevent - * reading files outside the worktree via symlinks. */ async readFile( worktreePath: string, @@ -29,33 +25,24 @@ export const secureFs = { encoding: BufferEncoding = "utf-8", ): Promise { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - }); + const fullPath = resolvePathInWorktree(worktreePath, filePath); return readFile(fullPath, encoding); }, /** * Read a file as a Buffer within a worktree. - * - * Validates path and checks for symlink escapes. */ async readFileBuffer( worktreePath: string, filePath: string, ): Promise { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - }); + const fullPath = resolvePathInWorktree(worktreePath, filePath); return readFile(fullPath); }, /** * Write content to a file within a worktree. - * - * Validates path and checks for symlink escapes to prevent - * writing files outside the worktree via symlinks. */ async writeFile( worktreePath: string, @@ -63,9 +50,7 @@ export const secureFs = { content: string, ): Promise { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - }); + const fullPath = resolvePathInWorktree(worktreePath, filePath); await writeFile(fullPath, content, "utf-8"); }, @@ -73,14 +58,13 @@ export const secureFs = { * Delete a file or directory within a worktree. * * DANGEROUS: Uses recursive + force deletion. - * Validates path and checks for symlink escapes. * Explicitly prevents deleting the worktree root. */ async delete(worktreePath: string, filePath: string): Promise { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - allowRoot: false, // Explicitly prevent deleting worktree root + // allowRoot: false prevents deleting the worktree itself + const fullPath = resolvePathInWorktree(worktreePath, filePath, { + allowRoot: false, }); await rm(fullPath, { recursive: true, force: true }); }, @@ -89,14 +73,10 @@ export const secureFs = { * Get file stats within a worktree. * * Uses `stat` (follows symlinks) to get the real file size. - * This is important for size checks - lstat would return - * the symlink size, not the target file size. */ async stat(worktreePath: string, filePath: string): Promise { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - }); + const fullPath = resolvePathInWorktree(worktreePath, filePath); return stat(fullPath); }, @@ -108,9 +88,7 @@ export const secureFs = { */ async lstat(worktreePath: string, filePath: string): Promise { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - }); + const fullPath = resolvePathInWorktree(worktreePath, filePath); return lstat(fullPath); }, @@ -122,9 +100,7 @@ export const secureFs = { async exists(worktreePath: string, filePath: string): Promise { try { assertRegisteredWorktree(worktreePath); - const fullPath = await resolveSecurePath(worktreePath, filePath, { - checkSymlinks: true, - }); + const fullPath = resolvePathInWorktree(worktreePath, filePath); await stat(fullPath); return true; } catch { From 9865aa18c3c5516ebc34b379108f720ea8906a59 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 09:36:39 +0200 Subject: [PATCH 15/64] feat(desktop): add configurable workspace navigation style (top-bar vs sidebar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a setting to let users choose between displaying workspaces as horizontal tabs in the TopBar (current behavior) or in a dedicated left sidebar (new feature, similar to Linear/GitHub Desktop). Key changes: - Add navigationStyle column to settings table (migration 0005) - Add navigation style dropdown in Behavior Settings - Create WorkspaceSidebar component with collapsible project sections - Create shared useWorkspaceShortcuts hook (⌘1-9 shortcuts, auto-create) - Update TopBar to conditionally render based on navigation style - Add ⌘⇧B hotkey to toggle workspace sidebar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .agents/commands/create-plan-file.md | 271 +++++ ...51231-1200-workspace-sidebar-navigation.md | 691 ++++++++++++ .../src/lib/trpc/routers/settings/index.ts | 21 + .../renderer/hooks/useWorkspaceShortcuts.ts | 117 +++ .../SettingsView/BehaviorSettings.tsx | 67 +- .../TopBar/WorkspaceSidebarControl.tsx | 40 + .../components/TopBar/WorkspaceTabs/index.tsx | 151 +-- .../screens/main/components/TopBar/index.tsx | 36 +- .../ProjectSection/ProjectHeader.tsx | 42 + .../ProjectSection/ProjectSection.tsx | 76 ++ .../WorkspaceSidebar/ProjectSection/index.ts | 2 + .../ResizableWorkspaceSidebar.tsx | 94 ++ .../WorkspaceListItem/WorkspaceDiffStats.tsx | 16 + .../WorkspaceListItem/WorkspaceListItem.tsx | 104 ++ .../WorkspaceStatusBadge.tsx | 53 + .../WorkspaceListItem/index.ts | 3 + .../WorkspaceSidebar/WorkspaceSidebar.tsx | 45 + .../WorkspaceSidebarFooter.tsx | 62 ++ .../WorkspaceSidebarHeader.tsx | 10 + .../main/components/WorkspaceSidebar/index.ts | 2 + .../src/renderer/screens/main/index.tsx | 22 +- apps/desktop/src/renderer/stores/index.ts | 1 + .../stores/workspace-sidebar-state.ts | 97 ++ apps/desktop/src/shared/constants.ts | 1 + apps/desktop/src/shared/hotkeys.ts | 7 +- .../drizzle/0005_add_navigation_style.sql | 1 + .../local-db/drizzle/meta/0005_snapshot.json | 984 ++++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 7 + packages/local-db/src/schema/schema.ts | 6 + 29 files changed, 2865 insertions(+), 164 deletions(-) create mode 100644 .agents/commands/create-plan-file.md create mode 100644 apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md create mode 100644 apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts create mode 100644 apps/desktop/src/renderer/stores/workspace-sidebar-state.ts create mode 100644 packages/local-db/drizzle/0005_add_navigation_style.sql create mode 100644 packages/local-db/drizzle/meta/0005_snapshot.json diff --git a/.agents/commands/create-plan-file.md b/.agents/commands/create-plan-file.md new file mode 100644 index 00000000000..3b092d269ed --- /dev/null +++ b/.agents/commands/create-plan-file.md @@ -0,0 +1,271 @@ +# Superset Execution Plans (ExecPlans): + +> **DO NOT EDIT THIS FILE** +> This file is the ExecPlan template and guide only. +> Create plans in the appropriate location: +> - **App-specific work**: `apps//.agents/plans/-.md` +> - **Package work**: `packages//.agents/plans/-.md` +> - **Cross-app/shared work**: `.agents/plans/-.md` (root) + +This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context. + +## Process + +Steps: +1. Discovery & Orientation: map the repo, name the scope, enumerate unknowns. Capture initial Assumptions and Open Questions in the ExecPlan. +2. Question-driven Clarification: ask focused, acceptance-oriented questions grouped by plan section. Maintain the Open Questions list in the ExecPlan and pre-link each item to a Decision Log placeholder. +3. Draft the Plan: complete the ExecPlan skeleton end-to-end (Purpose, Context, Plan of Work, Validation, Idempotence, etc.), calling out risks and dependencies. +4. Resolve Questions: as answers arrive, immediately update the ExecPlan—move items from Open Questions to the Decision Log with rationale; adjust Plan of Work and Acceptance accordingly. +5. Approval Gate: present the updated ExecPlan for approval. Do not implement until approved. +6. Implementation & Validation: implement per the plan, update Progress with timestamps, and validate via tests and acceptance. Log learnings in Surprises & Discoveries. +7. Closeout: write Outcomes & Retrospective; ensure the plan remains self-contained and accurate. +8. Write your plan to the appropriate location: + - App-specific: `apps//.agents/plans/-.md` + - Package-specific: `packages//.agents/plans/-.md` + - Cross-app: `.agents/plans/-.md` + Use `` in `YYYYMMDD-HHmm` format (e.g., `20240613-1045-my-feature-plan.md`). This ensures plans are sorted from most recent to oldest. +9. Plan Lifecycle: When the plan is complete and a PR is created, move it to the `done/` folder within the same directory. If abandoned, move it to `abandoned/`. + +Example questions: +``` +I reviewed the existing auth implementation in apps/web/src/app/auth/. + +Where should the new OAuth provider live? +a) apps/web/src/lib/auth/providers/ (co-located with auth logic) +b) packages/shared/src/auth/ (shared across apps) +c) Other (specify) + +How should we handle token refresh? +a) Silent refresh via interceptor +b) Explicit refresh on 401 response +c) Other (specify) +``` + +## How to use ExecPlans and PLANS.md + +When authoring an executable specification (ExecPlan), follow this document _to the letter_. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. + +When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently. + +When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. + +When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation. + +## Requirements + +NON-NEGOTIABLE REQUIREMENTS: + +* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed. +* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained. +* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo. +* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition". +* Every ExecPlan must define every term of art in plain language or do not use it. + +Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe. + +The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan. + +## Formatting + +Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists. + +When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks. + +Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first. + +## Guidelines + +Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself. + +Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details. + +Question discipline and placement: +- Keep each question atomic and acceptance-oriented (what behavior must hold? how will we observe it?). +- Record questions in an Open Questions section of the ExecPlan; tag each with the plan section it affects (e.g., Validation, Plan of Work). +- When a question is answered, create a Decision Log entry with rationale and update the affected sections. Remove the item from Open Questions. +- Prefer at most 3-7 active questions; timebox low-impact ones or convert them into explicit Assumptions. + +Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to http://localhost:3000/health returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior). + +Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable. + +Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go. + +Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project's toolchain and how to interpret their results. + +## Superset-Specific Context + +This is a Bun + Turborepo monorepo with the following structure: + +**Apps:** +- `apps/web` - Main web application (app.superset.sh) +- `apps/marketing` - Marketing site (superset.sh) +- `apps/admin` - Admin dashboard +- `apps/api` - API backend +- `apps/desktop` - Electron desktop application +- `apps/docs` - Documentation site +- `apps/cli` - CLI tooling + +**Packages:** +- `packages/ui` - Shared UI components (shadcn/ui + TailwindCSS v4) +- `packages/db` - Drizzle ORM database schema +- `packages/local-db` - Local database schema +- `packages/queries` - Shared query logic +- `packages/shared` - Shared constants and utilities +- `packages/trpc` - tRPC configuration + +**Common Commands:** +- `bun dev` - Start all dev servers +- `bun test` - Run tests +- `bun build` - Build all packages +- `bun run lint` - Check for lint issues +- `bun run lint:fix` - Fix auto-fixable lint issues +- `bun run typecheck` - Type check all packages +- `bun run db:push` - Apply schema changes +- `bun run db:migrate` - Run migrations + +Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs. + +## Milestones + +Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation. + +Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan. + +## Living plans and design decisions + +* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section. +* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional. +* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal). +* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you. +* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned. + +# Prototyping milestones and parallel implementations + +It is acceptable--and often encouraged--to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as "prototyping"; describe how to run and observe results; and state the criteria for promoting or discarding the prototype. + +Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation. + +## Skeleton of a Good ExecPlan + +```md +# + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +## Purpose / Big Picture + +Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable. + +## Assumptions + +State temporary assumptions that unblock planning. Every assumption must either be confirmed (moved to the Decision Log) or removed by implementation end. + +## Open Questions + +List unresolved, acceptance-oriented questions. For each, note the impacted plan sections (e.g., Validation, Plan of Work) and add a placeholder in the Decision Log for the eventual answer. + +## Progress + +Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two ("done" vs. "remaining"). This section must always reflect the actual current state of the work. + +- [x] (2025-10-01 13:00Z) Example completed step. +- [ ] Example incomplete step. +- [ ] Example partially completed step (completed: X; remaining: Y). + +Use timestamps to measure rates of progress. + +## Surprises & Discoveries + +Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence. + +- Observation: ... + Evidence: ... + +## Decision Log + +Record every decision made while working on the plan in the format: + +- Decision: ... + Rationale: ... + Date/Author: ... + +## Outcomes & Retrospective + +Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose. + +## Context and Orientation + +Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans. + +## Plan of Work + +Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal. + +## Concrete Steps + +State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds. + +## Validation and Acceptance + +Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run `bun test` and expect passed; the new test fails before the change and passes after". + +## Idempotence and Recovery + +If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion. + +## Artifacts and Notes + +Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success. + +## Interfaces and Dependencies + +Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `packages/db/src/schema/users.ts` or `apps/web/src/lib/auth.ts`. E.g.: + +In packages/db/src/schema/users.ts, define: + + export const users = pgTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + createdAt: timestamp('created_at').defaultNow(), + }); +``` + +If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED. + +When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything. + +## Plan Lifecycle + +ExecPlans have a defined lifecycle that keeps the `.agents/plans/` folder clean and provides a historical record of completed work. + +### Directory Structure + +``` +apps//.agents/plans/ # App-specific plans + .md + done/ + abandoned/ + +packages//.agents/plans/ # Package-specific plans + .md + done/ + abandoned/ + +.agents/plans/ # Cross-app/shared plans + .md + done/ + abandoned/ +``` + +### When to Move Plans + +**To `done/`**: Move the plan to the `done/` folder within the same directory when creating a PR that completes the work. Before moving, ensure the plan's `Outcomes & Retrospective` section is filled in. + +**To `abandoned/`**: Move the plan to the `abandoned/` folder within the same directory if work is stopped without completion. Add a note explaining why (scope changed, approach invalidated, deprioritized, etc.). + +### Edge Cases + +- **PR closed without merging**: The plan stays in `done/`. If work resumes, move it back to the active plans folder and update the `Progress` section. +- **Plan spans multiple PRs**: Keep the plan in the active folder until the final PR. Reference intermediate PRs in the `Progress` section, then move to `done/` on the final PR. +- **Reopening abandoned work**: Move the plan from `abandoned/` back to the active plans folder and update the `Progress` section to reflect the restart. diff --git a/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md b/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md new file mode 100644 index 00000000000..88aba9783e4 --- /dev/null +++ b/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md @@ -0,0 +1,691 @@ +# Configurable Workspace Navigation: Sidebar Mode + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +## Purpose / Big Picture + +Currently, workspaces are displayed as horizontal tabs in the TopBar, grouped by project. This change allows users to configure an alternative "sidebar" navigation style where workspaces appear in a dedicated left sidebar panel, matching designs from tools like Linear/GitHub Desktop. + +After this change, users can: +1. Open Settings > Behavior and toggle "Navigation style" between "Top bar" and "Sidebar" +2. In sidebar mode, see a dedicated workspace sidebar with collapsible project sections +3. Switch between workspaces by clicking items in the sidebar +4. See PR status, diff stats, and keyboard shortcuts inline with workspace items +5. Continue using ⌘1-9 shortcuts to switch workspaces regardless of mode + +## Design Reference + +The target design (based on provided mockup): + + ┌─────────────────────────────────────────────────────────────────────┐ + │ [Sidebar Toggle] [Workbench|Review] [Branch ▾] [Open In ▾] [Avatar]│ <- TopBar (sidebar mode) + ├──────────────────────┬──────────────────────────────────────────────┤ + │ ≡ Workspaces │ │ + │ │ │ + │ web │ │ + │ + New workspace ... │ Main Content Area │ + │ ┃ andreasasprou/cebu │ (Workbench or Review mode) │ + │ cebu · PR #144 │ │ + │ Ready to merge ⌘1 │ │ + │ +1850 -301 │ │ + │ │ │ + │ ▸ andreasasprou/feat │ │ + │ harare · PR #107 │ │ + │ Merge conflicts ⌘2 │ │ + │ ├──────────────────────────────────────────────┤ + │ nova │ │ + │ + New workspace ... │ Changes Sidebar │ + │ ┃ andreasasprou/pdf │ (existing ResizableSidebar) │ + │ la-paz-v2 · PR#720 │ │ + │ Uncommitted ⌘3 │ │ + │ +23823 -5 │ │ + │ │ │ + │ frontend │ │ + │ + New workspace ... │ │ + │ │ │ + │──────────────────── │ │ + │ [+] Add project │ │ + └──────────────────────┴──────────────────────────────────────────────┘ + Workspace Changes Content + Sidebar Sidebar (Mosaic Panes) + (NEW) (existing) + +Key visual elements: +- Active workspace: Green/project-colored left border (┃) +- Status badges: "Ready to merge", "Merge conflicts", "Uncommitted changes", "Archive" +- Diff stats: +insertions -deletions (always visible for active, hover for others) +- Keyboard shortcuts: ⌘1-9 displayed inline +- Collapsible project sections with header + "..." context menu +- "+ New workspace" per project section +- "Add project" at bottom footer + +## Assumptions + +1. The existing `WorkspaceHoverCard` already fetches PR status via `workspaces.getGitHubStatus` and can be reused +2. The `feat/desktop-workbench-review-mode` branch changes are the baseline (already rebased) +3. Users will primarily use one mode or the other, not switch frequently +4. The `packages/local-db` migration system handles schema changes on app startup + +## Open Questions + +(All questions resolved - see Decision Log) + +## Progress + +- [ ] Initial plan created and awaiting approval +- [ ] (Pending) Milestone 1: Add navigation style setting +- [ ] (Pending) Milestone 2: Create WorkspaceSidebar component +- [ ] (Pending) Milestone 3: Create sidebar-mode TopBar variant +- [ ] (Pending) Milestone 4: Wire up setting to conditionally render layouts +- [ ] (Pending) Milestone 5: Polish and validation + +## Surprises & Discoveries + +(To be filled during implementation) + +## Decision Log + +- **Decision**: Navigation style setting stored in SQLite settings table via existing tRPC pattern + - Rationale: Matches existing "confirmOnQuit" behavior setting pattern, persists across sessions + - Date: 2025-12-31 / Planning phase + +- **Decision**: Workspace sidebar is a NEW dedicated sidebar, not a mode in existing ModeCarousel + - Rationale: User preference for dedicated panel, keeps workspaces separate from terminal tabs/changes + - Date: 2025-12-31 / Planning phase + +- **Decision**: Both sidebars independently resizable + - Rationale: User may want different widths for workspace nav vs file changes + - Date: 2025-12-31 / Planning phase + +- **Decision**: ⌘1-9 shortcuts work in both navigation modes + - Rationale: Consistency for keyboard users regardless of UI layout preference + - Date: 2025-12-31 / Planning phase + +- **Decision**: Manual testing only, no automated tests for initial release + - Rationale: Feature is primarily UI/layout, visual verification more appropriate + - Date: 2025-12-31 / Planning phase + +- **Decision**: Workspace sidebar width persisted independently from changes sidebar + - Rationale: Users may want different widths for workspace nav vs file changes + - Date: 2025-12-31 / Planning phase + +- **Decision**: Workspace display format is "github-username/branch-name" (e.g., "andreasasprou/cebu") + - Rationale: Matches GitHub PR branch naming, provides author context + - Date: 2025-12-31 / Planning phase + +- **Decision**: Skip "Archive" status badge for initial release + - Rationale: Archive feature doesn't exist in app, can add later if needed + - Date: 2025-12-31 / Planning phase + +- **Decision**: Keep Workbench/Review toggle and Open In in WorkspaceActionBar, not TopBar + - Rationale: Avoids duplicating components, maintains consistent location across navigation modes + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Sidebar toggles use distinct naming: "Workspaces" and "Files" with different icons + - Rationale: With two sidebars, "Toggle sidebar" is ambiguous. Clear naming prevents confusion + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Workspace sidebar is toggleable (not always-on), default open on first use + - Rationale: Matches changes sidebar pattern, provides flexibility for screen sizes + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Use `workspaces.getGitHubStatus` for diff stats, lazy-load on hover + - Rationale: Reuses existing infrastructure, avoids N+1 queries, matches WorkspaceHoverCard + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Extract ⌘1-9 shortcuts and auto-create workspace logic into shared hook + - Rationale: These behaviors must work in BOTH navigation modes, avoiding code duplication + - Date: 2025-12-31 / Planning phase (review feedback) + +## Outcomes & Retrospective + +(To be filled at completion) + +--- + +## Context and Orientation + +### Current Architecture + +The desktop app (`apps/desktop/`) uses: + +**Layout Structure** (in `src/renderer/screens/main/`): +- `MainScreen` - Root component, manages view state (workspace/settings/tasks) +- `TopBar` - Contains `WorkspacesTabs` for horizontal workspace navigation +- `WorkspaceView` - Main content area with `ResizableSidebar` (changes) + `ContentView` + +**State Management**: +- `sidebar-state.ts` - Zustand store for changes sidebar (width, visibility, mode) +- `workspace-view-mode.ts` - Zustand store for Workbench/Review mode per workspace +- `app-state.ts` - Current view, settings section, etc. + +**Settings System**: +- Settings stored in SQLite via `packages/local-db/src/schema/schema.ts` +- tRPC routes in `src/lib/trpc/routers/settings/` +- UI in `src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` + +**Key Files**: +- `src/renderer/screens/main/components/TopBar/index.tsx` - Current TopBar +- `src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx` - Horizontal tabs +- `src/renderer/screens/main/components/WorkspaceView/index.tsx` - Main workspace layout +- `src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/` - Existing sidebar + +### Terminology + +- **Navigation style**: User preference for workspace display location ("top-bar" or "sidebar") +- **Workspace sidebar**: NEW left panel showing workspaces grouped by project +- **Changes sidebar**: EXISTING left panel showing git changes (file tree) +- **Workbench mode**: Terminal panes + file viewers (mosaic layout) +- **Review mode**: Full-page changes/diff view + +--- + +## Plan of Work + +### Milestone 1: Add Navigation Style Setting + +Add the setting infrastructure following the existing "confirmOnQuit" pattern. + +**1.1 Add setting to database schema** + +In `packages/local-db/src/schema/schema.ts`, add to settings table: + + navigationStyle: text("navigation_style").$type<"top-bar" | "sidebar">(), + +**1.2 Generate local-db migration** + +Run from `packages/local-db`: + + pnpm drizzle-kit generate --name="add_navigation_style" + +This creates a migration file in `packages/local-db/drizzle/`. The migration runs automatically on app startup via `apps/desktop/src/main/lib/local-db/index.ts` migrate logic. + +**IMPORTANT**: Do NOT use `bun run db:push` - that targets packages/db (Neon/Postgres), not local-db. + +**1.3 Add default constant** + +In `apps/desktop/src/shared/constants.ts`: + + export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; + export type NavigationStyle = "top-bar" | "sidebar"; + +**1.4 Add tRPC routes** + +In `apps/desktop/src/lib/trpc/routers/settings/index.ts`, add: + + getNavigationStyle: publicProcedure.query(async () => { + const row = getSettings(); + return row.navigationStyle ?? DEFAULT_NAVIGATION_STYLE; + }), + + setNavigationStyle: publicProcedure + .input(z.object({ style: z.enum(["top-bar", "sidebar"]) })) + .mutation(async ({ input }) => { + localDb.insert(settings) + .values({ id: 1, navigationStyle: input.style }) + .onConflictDoUpdate({ + target: settings.id, + set: { navigationStyle: input.style } + }) + .run(); + return { success: true }; + }), + +**1.5 Add UI in BehaviorSettings** + +In `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx`, add a toggle/select for "Navigation style" with options "Top bar" and "Sidebar". + +### Milestone 2: Create WorkspaceSidebar Component + +Create the new sidebar component matching the design. + +**2.1 Create store for workspace sidebar state** + +Create `apps/desktop/src/renderer/stores/workspace-sidebar-state.ts`: + + interface WorkspaceSidebarState { + isOpen: boolean; + width: number; + // Use string[] instead of Set for JSON serialization with Zustand persist + collapsedProjectIds: string[]; + toggleOpen: () => void; + setWidth: (width: number) => void; + toggleProjectCollapsed: (projectId: string) => void; + isProjectCollapsed: (projectId: string) => boolean; + } + +**NOTE**: Do NOT use `Set` for `collapsedProjectIds` - Zustand persist uses JSON serialization which drops Sets. Use `string[]` and provide helper methods. + +**2.2 Create component structure** + +Create folder: `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/` + +Files to create: +- `index.tsx` - Main component +- `WorkspaceSidebarHeader.tsx` - "Workspaces" header with icon +- `ProjectSection/ProjectSection.tsx` - Collapsible project group +- `ProjectSection/ProjectHeader.tsx` - Project name + actions +- `WorkspaceListItem/WorkspaceListItem.tsx` - Individual workspace row +- `WorkspaceListItem/WorkspaceStatusBadge.tsx` - Status badges +- `WorkspaceListItem/WorkspaceDiffStats.tsx` - +/- diff display +- `WorkspaceSidebarFooter.tsx` - "Add project" button + +**2.3 WorkspaceListItem design** + +Each workspace item displays: +- Left border (project color when active) +- Branch icon (git-branch, git-pull-request, etc. based on type) +- Author/branch: "andreasasprou/feature-name" +- Worktree name + PR info: "worktree-city · PR #123" +- Status badge: "Ready to merge" / "Merge conflicts" / "Uncommitted changes" / "Archive" +- Keyboard shortcut badge: "⌘1" +- Diff stats (for active): "+1850 -301" + +**2.4 Data fetching** + +Reuse existing queries: +- `trpc.workspaces.getAllGrouped.useQuery()` for project/workspace list +- `trpc.workspaces.getActive.useQuery()` for active workspace + +**Diff stats source**: Use `workspaces.getGitHubStatus` (already used by WorkspaceHoverCard) for PR additions/deletions. This is the authoritative source. Do NOT add a new git diff endpoint. For workspaces without PRs, show local uncommitted changes count from the changes router as fallback. + +**Performance consideration**: Avoid N+1 `getGitHubStatus` calls per workspace row. Options: +1. Extend `getAllGrouped` to include a summary `githubStatus` field (batched) +2. Reuse cached data from `worktrees.githubStatus` if already fetched +3. Lazy-load status on hover only (simplest, matches current WorkspaceHoverCard behavior) + +Recommended: Start with option 3 (lazy-load on hover) to match existing patterns, then optimize with batching if performance is an issue. + +**2.5 Extract shared workspace behaviors** + +Currently `WorkspacesTabs/index.tsx` owns critical behaviors that must work in BOTH navigation modes: +- ⌘1-9 workspace switching shortcuts +- Auto-create main workspace for new projects effect + +Create a shared hook: `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts` + + export function useWorkspaceShortcuts() { + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const setActiveWorkspace = useSetActiveWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + // Flatten workspaces for ⌘1-9 navigation + const allWorkspaces = groups.flatMap((group) => group.workspaces); + + // ⌘1-9 shortcuts + useHotkeys(workspaceKeys, handleWorkspaceSwitch); + useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); + useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); + + // Auto-create main workspace for new projects + useEffect(() => { /* existing logic */ }, [groups]); + + return { allWorkspaces }; + } + +Then use this hook in BOTH: +- `WorkspaceSidebar/index.tsx` (sidebar mode) +- `WorkspacesTabs/index.tsx` (top-bar mode) + +This ensures shortcuts work regardless of navigation style. + +### Milestone 3: Create Sidebar-Mode TopBar Variant + +When navigation style is "sidebar", the TopBar should show a unified bar without workspace tabs. + +**3.1 Decide on control placement** + +Currently, `WorkspaceActionBar` contains: +- ViewModeToggle (Workbench/Review) +- Branch selector +- Open In dropdown + +**Decision needed**: In sidebar mode, do these controls: +A) Stay in WorkspaceActionBar (below TopBar) - no duplication, consistent location +B) Move to TopBarSidebarMode - more prominent, frees up vertical space + +**Recommendation**: Keep controls in WorkspaceActionBar (option A). This: +- Avoids duplicating components +- Maintains consistent location across modes +- Keeps TopBar focused on navigation + +TopBarSidebarMode then only needs: +- Changes sidebar toggle (existing SidebarControl, renamed for clarity) +- Workspace sidebar toggle (new) +- Avatar/user menu + +**3.2 Sidebar toggle disambiguation** + +With two sidebars, we need clear naming: +- **"Files" / file icon**: Toggle changes sidebar (existing, currently just "sidebar") +- **"Workspaces" / layers icon**: Toggle workspace sidebar (new) + +Update tooltips and potentially add labels on hover. Both toggles live in TopBar. + +**3.3 Create TopBarSidebarMode component** + +Create `apps/desktop/src/renderer/screens/main/components/TopBar/TopBarSidebarMode.tsx`: + +Layout (left to right): +- Workspace sidebar toggle (new, tooltip: "Toggle workspaces") +- Changes sidebar toggle (existing SidebarControl, tooltip: "Toggle files") +- [Spacer] +- [Right] Avatar dropdown + +The Workbench/Review toggle, branch selector, and Open In dropdown remain in WorkspaceActionBar. + +**3.4 Workspace sidebar always-on vs toggleable** + +The workspace sidebar should be toggleable (not always-on) because: +- Users may want full-width content when not switching workspaces +- Matches the existing changes sidebar pattern +- Provides flexibility for different screen sizes + +Default state: Open (on first use), then persisted via Zustand. + +**3.5 Conditional rendering in TopBar** + +Modify `apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx`: + + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + + if (navigationStyle === "sidebar") { + return ; + } + + return ; // Rename current implementation + +### Milestone 4: Wire Up Layout Switching + +Connect the setting to conditionally render the appropriate layout. + +**4.1 Modify MainScreen layout** + +In `apps/desktop/src/renderer/screens/main/index.tsx`, when rendering workspace view: + + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + + // In render: + {navigationStyle === "sidebar" && } + + +**4.2 Modify WorkspaceView** + +The `WorkspaceView` component remains largely unchanged - it already has the ResizableSidebar (changes) and ContentView. The WorkspaceSidebar sits to its left. + +**4.3 Layout structure in sidebar mode** + +
+ {/* NEW - workspace navigation */} + {/* EXISTING - contains changes sidebar + content */} +
+ +### Milestone 5: Polish and Validation + +**5.1 Keyboard shortcuts** + +Ensure ⌘1-9 workspace switching works in both modes. The existing `useHotkeys` in `WorkspacesTabs/index.tsx` should be moved/shared. + +**5.2 Hover preview** + +Implement hover preview showing branch + PR status. Can reuse `WorkspaceHoverCard` component or adapt it. + +**5.3 Animations** + +- Smooth sidebar show/hide with Framer Motion +- Collapse/expand project sections with animation +- Active workspace indicator transition + +**5.4 Persistence** + +- Workspace sidebar width persists (Zustand + localStorage) +- Collapsed project sections persist +- Navigation style persists (SQLite) + +--- + +## Concrete Steps + +All commands run from repository root: `/Users/andreasasprou/.superset/worktrees/superset/workspace-sidebar` + +**Step 1: Verify current state** + + cd apps/desktop + bun run typecheck + +Expected: No type errors (baseline) + +**Step 2: Add database schema field and generate migration** + +Edit `packages/local-db/src/schema/schema.ts` to add `navigationStyle` column. + + cd packages/local-db + pnpm drizzle-kit generate --name="add_navigation_style" + +Expected: Migration file created in `packages/local-db/drizzle/` + +The migration runs automatically on app startup. Do NOT use `bun run db:push` (that's for Neon/Postgres). + +**Step 3: Add setting routes** + +Edit `apps/desktop/src/lib/trpc/routers/settings/index.ts` + + bun run typecheck + +Expected: Types pass with new routes + +**Step 4: Create WorkspaceSidebar component** + +Create component files as specified in Milestone 2. + +**Step 5: Add to layout** + +Wire up conditional rendering in MainScreen. + + bun dev + +Expected: App starts, can toggle setting, layout switches + +**Step 6: Full validation** + + bun run lint:fix + bun run typecheck + bun test + +Expected: All pass + +--- + +## Validation and Acceptance + +### Manual Testing Checklist + +1. **Setting toggle works** + - Open Settings > Behavior + - See "Navigation style" option + - Toggle between "Top bar" and "Sidebar" + - Layout changes immediately (or after brief transition) + +2. **Sidebar mode displays correctly** + - WorkspaceSidebar appears on left + - Projects shown as collapsible sections + - Workspaces listed under each project + - Active workspace has colored left border + - Status badges visible + - Diff stats visible for active workspace + - ⌘1-9 shortcuts displayed + +3. **Interactions work** + - Click workspace to switch + - Click project header to collapse/expand + - Hover shows preview card + - ⌘1-9 switches workspaces + - "+ New workspace" opens creation dialog + - "Add project" opens project creation + +4. **TopBar adapts** + - In sidebar mode: No workspace tabs, unified bar with Workbench/Review toggle + - In top-bar mode: Original layout preserved + +5. **Persistence** + - Close and reopen app + - Navigation style preserved + - Sidebar widths preserved + - Collapsed projects preserved + +6. **Both sidebars coexist** + - Workspace sidebar (left) + - Changes sidebar (right of workspace sidebar) + - Both independently resizable + - Both can be toggled independently + +--- + +## Idempotence and Recovery + +- Database schema changes are additive (new nullable column) +- Running `db:push` multiple times is safe +- Component files are new additions, no destructive changes +- Setting defaults to "top-bar" if not set (backwards compatible) +- If implementation fails partway, the existing top-bar mode continues working + +--- + +## Artifacts and Notes + +### Component File Structure + + apps/desktop/src/renderer/ + ├── hooks/ + │ └── useWorkspaceShortcuts.ts (new - shared ⌘1-9 + auto-create logic) + ├── screens/main/components/ + │ ├── WorkspaceSidebar/ + │ │ ├── index.tsx + │ │ ├── WorkspaceSidebarHeader.tsx + │ │ ├── WorkspaceSidebarFooter.tsx + │ │ ├── ResizableWorkspaceSidebar.tsx (wrapper with resize handle) + │ │ ├── ProjectSection/ + │ │ │ ├── ProjectSection.tsx + │ │ │ ├── ProjectHeader.tsx + │ │ │ └── index.ts + │ │ └── WorkspaceListItem/ + │ │ ├── WorkspaceListItem.tsx + │ │ ├── WorkspaceStatusBadge.tsx + │ │ ├── WorkspaceDiffStats.tsx + │ │ └── index.ts + │ └── TopBar/ + │ ├── index.tsx (modified - conditional render) + │ ├── TopBarSidebarMode.tsx (new) + │ ├── TopBarDefault.tsx (renamed from inline JSX) + │ ├── SidebarControl.tsx (updated tooltip: "Toggle files") + │ ├── WorkspaceSidebarControl.tsx (new - "Toggle workspaces") + │ └── ... (existing files) + └── stores/ + └── workspace-sidebar-state.ts (new) + +### State Structure + + // workspace-sidebar-state.ts (Zustand + persist) + { + isOpen: true, + width: 280, // pixels + collapsedProjectIds: ["project-id-1", "project-id-2"], // string[] NOT Set + } + + // settings table (SQLite via local-db) + { + navigationStyle: "sidebar" | "top-bar" + } + +**Important**: Use `string[]` for `collapsedProjectIds`, not `Set`. Zustand persist uses JSON serialization which drops Sets. + +--- + +## Interfaces and Dependencies + +### New tRPC Routes + +In `apps/desktop/src/lib/trpc/routers/settings/index.ts`: + + getNavigationStyle: publicProcedure.query(() => NavigationStyle) + setNavigationStyle: publicProcedure.input({ style: NavigationStyle }).mutation() + +### New Zustand Store + +In `apps/desktop/src/renderer/stores/workspace-sidebar-state.ts`: + + export const useWorkspaceSidebarStore = create()( + devtools( + persist( + (set, get) => ({ + isOpen: true, + width: 280, + collapsedProjectIds: [], // string[] for JSON serialization + + toggleOpen: () => set((s) => ({ isOpen: !s.isOpen })), + + setWidth: (width) => set({ width }), + + toggleProjectCollapsed: (projectId) => + set((s) => ({ + collapsedProjectIds: s.collapsedProjectIds.includes(projectId) + ? s.collapsedProjectIds.filter((id) => id !== projectId) + : [...s.collapsedProjectIds, projectId], + })), + + isProjectCollapsed: (projectId) => + get().collapsedProjectIds.includes(projectId), + }), + { name: "workspace-sidebar-store" } + ) + ) + ); + +### New Shared Hook + +In `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts`: + + export function useWorkspaceShortcuts() { + // Extract from WorkspacesTabs: ⌘1-9 shortcuts + auto-create logic + // Used by BOTH WorkspaceSidebar and WorkspacesTabs + } + +### Component Props + + interface WorkspaceListItemProps { + workspace: { + id: string; + name: string; + branch: string; + worktreePath: string; + type: "worktree" | "branch"; + projectId: string; + }; + project: { + id: string; + name: string; + color: string; + }; + isActive: boolean; + index: number; // for ⌘N shortcut display + onSelect: () => void; + onHover: () => void; + } + +--- + +## Dependencies on External Data + +The following data is needed for full feature parity with the design: + +1. **PR Status + Diff Stats** - Available via `workspaces.getGitHubStatus` (already used by WorkspaceHoverCard) + - This is the authoritative source for PR additions/deletions + - Do NOT add a new git diff endpoint + +2. **Workspace Status** (uncommitted changes) - Available via changes router + - Fallback for workspaces without PRs + +3. **GitHub Author/Branch** - Extract from PR branch name or remote tracking branch + - Already available in workspace data + +**Performance strategy**: Lazy-load status on hover (matching WorkspaceHoverCard behavior). If batching is needed later, extend `getAllGrouped` to include summary status. diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 8c2e5ed6e83..abc5cd8f903 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -6,6 +6,7 @@ import { import { localDb } from "main/lib/local-db"; import { DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_NAVIGATION_STYLE, DEFAULT_TERMINAL_LINK_BEHAVIOR, } from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; @@ -207,5 +208,25 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getNavigationStyle: publicProcedure.query(() => { + const row = getSettings(); + return row.navigationStyle ?? DEFAULT_NAVIGATION_STYLE; + }), + + setNavigationStyle: publicProcedure + .input(z.object({ style: z.enum(["top-bar", "sidebar"]) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, navigationStyle: input.style }) + .onConflictDoUpdate({ + target: settings.id, + set: { navigationStyle: input.style }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts new file mode 100644 index 00000000000..cd222045883 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { trpc } from "renderer/lib/trpc"; +import { + useCreateBranchWorkspace, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; +import { HOTKEYS } from "shared/hotkeys"; + +/** + * Shared hook for workspace keyboard shortcuts and auto-creation logic. + * This hook should be used in both: + * - WorkspacesTabs (top-bar mode) + * - WorkspaceSidebar (sidebar mode) + * + * It handles: + * - ⌘1-9 workspace switching shortcuts + * - Previous/next workspace shortcuts + * - Auto-create main workspace for new projects + */ +export function useWorkspaceShortcuts() { + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id || null; + const setActiveWorkspace = useSetActiveWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + // Track projects we've attempted to create workspaces for (persists across renders) + const attemptedProjectsRef = useRef>(new Set()); + const [isCreating, setIsCreating] = useState(false); + + // Auto-create main workspace for new projects (one-time per project) + useEffect(() => { + if (isCreating) return; + + for (const group of groups) { + const projectId = group.project.id; + const hasMainWorkspace = group.workspaces.some( + (w) => w.type === "branch", + ); + + // Skip if already has main workspace or we've already attempted this project + if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { + continue; + } + + // Mark as attempted before creating (prevents retries) + attemptedProjectsRef.current.add(projectId); + setIsCreating(true); + + // Auto-create fails silently - this is a background convenience feature + createBranchWorkspace.mutate( + { projectId }, + { + onSettled: () => { + setIsCreating(false); + }, + }, + ); + // Only create one at a time + break; + } + }, [groups, isCreating, createBranchWorkspace]); + + // Flatten workspaces for keyboard navigation + const allWorkspaces = groups.flatMap((group) => group.workspaces); + + // Workspace switching shortcuts (⌘+1-9) + const workspaceKeys = Array.from( + { length: 9 }, + (_, i) => `meta+${i + 1}`, + ).join(", "); + + const handleWorkspaceSwitch = useCallback( + (event: KeyboardEvent) => { + const num = Number(event.key); + if (num >= 1 && num <= 9) { + const workspace = allWorkspaces[num - 1]; + if (workspace) { + setActiveWorkspace.mutate({ id: workspace.id }); + } + } + }, + [allWorkspaces, setActiveWorkspace], + ); + + const handlePrevWorkspace = useCallback(() => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex > 0) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); + } + }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + + const handleNextWorkspace = useCallback(() => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex < allWorkspaces.length - 1) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); + } + }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + + useHotkeys(workspaceKeys, handleWorkspaceSwitch); + useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); + useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); + + return { + groups, + allWorkspaces, + activeWorkspaceId, + setActiveWorkspace, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx index bdc930ec4f0..bbcd9c9d80f 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -10,33 +10,52 @@ import { import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; +type NavigationStyle = "top-bar" | "sidebar"; + export function BehaviorSettings() { const utils = trpc.useUtils(); - const { data: confirmOnQuit, isLoading } = + + // Confirm on quit setting + const { data: confirmOnQuit, isLoading: isConfirmLoading } = trpc.settings.getConfirmOnQuit.useQuery(); const setConfirmOnQuit = trpc.settings.setConfirmOnQuit.useMutation({ onMutate: async ({ enabled }) => { - // Cancel outgoing fetches await utils.settings.getConfirmOnQuit.cancel(); - // Snapshot previous value const previous = utils.settings.getConfirmOnQuit.getData(); - // Optimistically update utils.settings.getConfirmOnQuit.setData(undefined, enabled); return { previous }; }, onError: (_err, _vars, context) => { - // Rollback on error if (context?.previous !== undefined) { utils.settings.getConfirmOnQuit.setData(undefined, context.previous); } }, onSettled: () => { - // Refetch to ensure sync with server utils.settings.getConfirmOnQuit.invalidate(); }, }); - const handleToggle = (enabled: boolean) => { + // Navigation style setting + const { data: navigationStyle, isLoading: isNavLoading } = + trpc.settings.getNavigationStyle.useQuery(); + const setNavigationStyle = trpc.settings.setNavigationStyle.useMutation({ + onMutate: async ({ style }) => { + await utils.settings.getNavigationStyle.cancel(); + const previous = utils.settings.getNavigationStyle.getData(); + utils.settings.getNavigationStyle.setData(undefined, style); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getNavigationStyle.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getNavigationStyle.invalidate(); + }, + }); + + const handleConfirmToggle = (enabled: boolean) => { setConfirmOnQuit.mutate({ enabled }); }; @@ -71,6 +90,10 @@ export function BehaviorSettings() { }); }; + const handleNavigationStyleChange = (style: NavigationStyle) => { + setNavigationStyle.mutate({ style }); + }; + return (
@@ -81,6 +104,32 @@ export function BehaviorSettings() {
+ {/* Navigation Style */} +
+
+ +

+ Choose how workspaces are displayed +

+
+ +
+ + {/* Confirm on Quit */}
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx new file mode 100644 index 00000000000..9f73f0e465b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -0,0 +1,40 @@ +import { Button } from "@superset/ui/button"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuPanelLeft, LuPanelLeftClose } from "react-icons/lu"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { HOTKEYS } from "shared/hotkeys"; + +export function WorkspaceSidebarControl() { + const { isOpen, toggleOpen } = useWorkspaceSidebarStore(); + + return ( + + + + + + + Toggle Workspaces + + {HOTKEYS.TOGGLE_WORKSPACE_SIDEBAR.display.map((key) => ( + {key} + ))} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx index 27c58551259..8c8455c85ad 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -1,14 +1,9 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { - useCreateBranchWorkspace, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; import { useCurrentView, useIsSettingsTabOpen, } from "renderer/stores/app-state"; -import { useAppHotkey } from "renderer/stores/hotkeys"; import { CreateWorkspaceButton } from "./CreateWorkspaceButton"; import { SettingsTab } from "./SettingsTab"; import { WorkspaceGroup } from "./WorkspaceGroup"; @@ -18,11 +13,9 @@ const MAX_WORKSPACE_WIDTH = 160; const ADD_BUTTON_WIDTH = 40; export function WorkspacesTabs() { - const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id || null; - const setActiveWorkspace = useSetActiveWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); + // Use shared hook for workspace shortcuts and auto-create logic + const { groups, allWorkspaces, activeWorkspaceId } = useWorkspaceShortcuts(); + const currentView = useCurrentView(); const isSettingsTabOpen = useIsSettingsTabOpen(); const isSettingsActive = currentView === "settings"; @@ -35,140 +28,6 @@ export function WorkspacesTabs() { null, ); - // Track projects we've attempted to create workspaces for (persists across renders) - // Using ref to avoid re-triggering the effect - const attemptedProjectsRef = useRef>(new Set()); - const [isCreating, setIsCreating] = useState(false); - - // Auto-create main workspace for new projects (one-time per project) - // This only runs for projects we haven't attempted yet - useEffect(() => { - if (isCreating) return; - - for (const group of groups) { - const projectId = group.project.id; - const hasMainWorkspace = group.workspaces.some( - (w) => w.type === "branch", - ); - - // Skip if already has main workspace or we've already attempted this project - if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { - continue; - } - - // Mark as attempted before creating (prevents retries) - attemptedProjectsRef.current.add(projectId); - setIsCreating(true); - - // Auto-create fails silently - this is a background convenience feature - // Users can manually create the workspace via the dropdown if needed - createBranchWorkspace.mutate( - { projectId }, - { - onSettled: () => { - setIsCreating(false); - }, - }, - ); - // Only create one at a time - break; - } - }, [groups, isCreating, createBranchWorkspace]); - - // Flatten workspaces for keyboard navigation - const allWorkspaces = groups.flatMap((group) => group.workspaces); - - const handleWorkspaceSwitch = useCallback( - (index: number) => { - const workspace = allWorkspaces[index]; - if (workspace) { - setActiveWorkspace.mutate({ id: workspace.id }); - } - }, - [allWorkspaces, setActiveWorkspace], - ); - - const handlePrevWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex > 0) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - - const handleNextWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex < allWorkspaces.length - 1) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - - useAppHotkey( - "JUMP_TO_WORKSPACE_1", - () => handleWorkspaceSwitch(0), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_2", - () => handleWorkspaceSwitch(1), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_3", - () => handleWorkspaceSwitch(2), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_4", - () => handleWorkspaceSwitch(3), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_5", - () => handleWorkspaceSwitch(4), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_6", - () => handleWorkspaceSwitch(5), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_7", - () => handleWorkspaceSwitch(6), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_8", - () => handleWorkspaceSwitch(7), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_9", - () => handleWorkspaceSwitch(8), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey("PREV_WORKSPACE", handlePrevWorkspace, undefined, [ - handlePrevWorkspace, - ]); - useAppHotkey("NEXT_WORKSPACE", handleNextWorkspace, undefined, [ - handleNextWorkspace, - ]); - useEffect(() => { const checkScroll = () => { if (!scrollRef.current) return; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 2875da88b89..2441e996563 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,25 +1,49 @@ +import type { NavigationStyle } from "@superset/local-db"; import { trpc } from "renderer/lib/trpc"; import { AvatarDropdown } from "../AvatarDropdown"; import { SidebarControl } from "./SidebarControl"; import { WindowControls } from "./WindowControls"; +import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; import { WorkspacesTabs } from "./WorkspaceTabs"; -export function TopBar() { +interface TopBarProps { + navigationStyle?: NavigationStyle; +} + +export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { const { data: platform } = trpc.window.getPlatform.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const isMac = platform === "darwin"; + const isSidebarMode = navigationStyle === "sidebar"; + return ( -
+
+ {isSidebarMode && }
-
- -
+ + {isSidebarMode ? ( +
+ {activeWorkspace && ( + + {activeWorkspace.project?.name ?? "Workspace"} + / + {activeWorkspace.name} + + )} +
+ ) : ( +
+ +
+ )} +
{!isMac && } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx new file mode 100644 index 00000000000..85a1fe3d72a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -0,0 +1,42 @@ +import { cn } from "@superset/ui/utils"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; + +interface ProjectHeaderProps { + projectName: string; + projectColor: string; + isCollapsed: boolean; + onToggleCollapse: () => void; + workspaceCount: number; +} + +export function ProjectHeader({ + projectName, + projectColor, + isCollapsed, + onToggleCollapse, + workspaceCount, +}: ProjectHeaderProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx new file mode 100644 index 00000000000..41a5e5f9e20 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -0,0 +1,76 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { WorkspaceListItem } from "../WorkspaceListItem"; +import { ProjectHeader } from "./ProjectHeader"; + +interface Workspace { + id: string; + projectId: string; + worktreePath: string; + type: "worktree" | "branch"; + branch: string; + name: string; + tabOrder: number; +} + +interface ProjectSectionProps { + projectId: string; + projectName: string; + projectColor: string; + workspaces: Workspace[]; + activeWorkspaceId: string | null; + /** Base index for keyboard shortcuts (0-based) */ + shortcutBaseIndex: number; +} + +export function ProjectSection({ + projectId, + projectName, + projectColor, + workspaces, + activeWorkspaceId, + shortcutBaseIndex, +}: ProjectSectionProps) { + const { isProjectCollapsed, toggleProjectCollapsed } = + useWorkspaceSidebarStore(); + + const isCollapsed = isProjectCollapsed(projectId); + + return ( +
+ toggleProjectCollapsed(projectId)} + workspaceCount={workspaces.length} + /> + + + {!isCollapsed && ( + +
+ {workspaces.map((workspace, index) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts new file mode 100644 index 00000000000..2111af01d6b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts @@ -0,0 +1,2 @@ +export { ProjectHeader } from "./ProjectHeader"; +export { ProjectSection } from "./ProjectSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx new file mode 100644 index 00000000000..526fa283d2c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx @@ -0,0 +1,94 @@ +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef } from "react"; +import { + MAX_WORKSPACE_SIDEBAR_WIDTH, + MIN_WORKSPACE_SIDEBAR_WIDTH, + useWorkspaceSidebarStore, +} from "renderer/stores"; +import { WorkspaceSidebar } from "./WorkspaceSidebar"; + +export function ResizableWorkspaceSidebar() { + const { isOpen, width, setWidth, isResizing, setIsResizing } = + useWorkspaceSidebarStore(); + + const startXRef = useRef(0); + const startWidthRef = useRef(0); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + startXRef.current = e.clientX; + startWidthRef.current = width; + setIsResizing(true); + }, + [width, setIsResizing], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + const delta = e.clientX - startXRef.current; + const newWidth = startWidthRef.current + delta; + const clampedWidth = Math.max( + MIN_WORKSPACE_SIDEBAR_WIDTH, + Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, newWidth), + ); + setWidth(clampedWidth); + }, + [isResizing, setWidth], + ); + + const handleMouseUp = useCallback(() => { + if (isResizing) { + setIsResizing(false); + } + }, [isResizing, setIsResizing]); + + useEffect(() => { + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + if (!isOpen) { + return null; + } + + return ( +
+ + + {/* Resize handle */} + {/* biome-ignore lint/a11y/useSemanticElements:
is not appropriate for interactive resize handles */} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx new file mode 100644 index 00000000000..a2758d40b01 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx @@ -0,0 +1,16 @@ +interface WorkspaceDiffStatsProps { + additions: number; + deletions: number; +} + +export function WorkspaceDiffStats({ + additions, + deletions, +}: WorkspaceDiffStatsProps) { + return ( +
+ +{additions} + -{deletions} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx new file mode 100644 index 00000000000..a819fbb3413 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -0,0 +1,104 @@ +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { LuGitBranch } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; +import { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; + +interface WorkspaceListItemProps { + id: string; + name: string; + branch: string; + type: "worktree" | "branch"; + isActive: boolean; + shortcutIndex?: number; +} + +export function WorkspaceListItem({ + id, + name, + branch, + type, + isActive, + shortcutIndex, +}: WorkspaceListItemProps) { + const setActiveWorkspace = useSetActiveWorkspace(); + const [hasHovered, setHasHovered] = useState(false); + + // Lazy-load GitHub status on hover to avoid N+1 queries + const { data: githubStatus } = trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: id }, + { + enabled: hasHovered && type === "worktree", + staleTime: 30_000, + }, + ); + + const handleClick = () => { + setActiveWorkspace.mutate({ id }); + }; + + const handleMouseEnter = () => { + if (!hasHovered) { + setHasHovered(true); + } + }; + + const pr = githubStatus?.pr; + const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx new file mode 100644 index 00000000000..d6eb509d730 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx @@ -0,0 +1,53 @@ +import { cn } from "@superset/ui/utils"; +import { LuCircleDot, LuGitMerge, LuGitPullRequest } from "react-icons/lu"; + +type PRState = "open" | "merged" | "closed" | "draft"; + +interface WorkspaceStatusBadgeProps { + state: PRState; + prNumber?: number; +} + +export function WorkspaceStatusBadge({ + state, + prNumber, +}: WorkspaceStatusBadgeProps) { + const iconClass = "w-3 h-3"; + + const config = { + open: { + icon: , + bgColor: "bg-emerald-500/10", + }, + merged: { + icon: , + bgColor: "bg-purple-500/10", + }, + closed: { + icon: , + bgColor: "bg-destructive/10", + }, + draft: { + icon: ( + + ), + bgColor: "bg-muted", + }, + }; + + const { icon, bgColor } = config[state]; + + return ( +
+ {icon} + {prNumber && ( + #{prNumber} + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts new file mode 100644 index 00000000000..4dd9ef18a6e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts @@ -0,0 +1,3 @@ +export { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +export { WorkspaceListItem } from "./WorkspaceListItem"; +export { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx new file mode 100644 index 00000000000..c4d68290bfb --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -0,0 +1,45 @@ +import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { ProjectSection } from "./ProjectSection"; +import { WorkspaceSidebarFooter } from "./WorkspaceSidebarFooter"; +import { WorkspaceSidebarHeader } from "./WorkspaceSidebarHeader"; + +export function WorkspaceSidebar() { + const { groups, activeWorkspaceId } = useWorkspaceShortcuts(); + + // Calculate shortcut base indices for each project group + let shortcutIndex = 0; + const projectShortcutIndices = groups.map((group) => { + const baseIndex = shortcutIndex; + shortcutIndex += group.workspaces.length; + return baseIndex; + }); + + return ( +
+ + +
+ {groups.map((group, index) => ( + + ))} + + {groups.length === 0 && ( +
+ No workspaces yet + Add a project to get started +
+ )} +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx new file mode 100644 index 00000000000..99c4117a96d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx @@ -0,0 +1,62 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { LuFolderPlus } from "react-icons/lu"; +import { useOpenNew } from "renderer/react-query/projects"; +import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; + +export function WorkspaceSidebarFooter() { + const openNew = useOpenNew(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + const handleOpenNewProject = async () => { + try { + const result = await openNew.mutateAsync(undefined); + if (result.canceled) { + return; + } + if ("error" in result) { + toast.error("Failed to open project", { + description: result.error, + }); + return; + } + if ("needsGitInit" in result) { + toast.error("Selected folder is not a git repository", { + description: + "Please use 'Open project' from the start view to initialize git.", + }); + return; + } + // Create a main workspace on the current branch for the new project + toast.promise( + createBranchWorkspace.mutateAsync({ projectId: result.project.id }), + { + loading: "Opening project...", + success: "Project opened", + error: (err) => + err instanceof Error ? err.message : "Failed to open project", + }, + ); + } catch (error) { + toast.error("Failed to open project", { + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx new file mode 100644 index 00000000000..bc54cd8e98b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx @@ -0,0 +1,10 @@ +import { LuLayers } from "react-icons/lu"; + +export function WorkspaceSidebarHeader() { + return ( +
+ + Workspaces +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts new file mode 100644 index 00000000000..d8dc226739d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts @@ -0,0 +1,2 @@ +export { ResizableWorkspaceSidebar } from "./ResizableWorkspaceSidebar"; +export { WorkspaceSidebar } from "./WorkspaceSidebar"; diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 98f2cc947a2..0e3ebeb0996 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { DndProvider } from "react-dnd"; import { HiArrowPath } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; @@ -19,6 +20,9 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import type { Tab } from "renderer/stores/tabs/types"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; +import { useWorkspaceSidebarStore } from "renderer/stores/workspace-sidebar-state"; +import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; +import { HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -26,6 +30,7 @@ import { SettingsView } from "./components/SettingsView"; import { StartView } from "./components/StartView"; import { TasksView } from "./components/TasksView"; import { TopBar } from "./components/TopBar"; +import { ResizableWorkspaceSidebar } from "./components/WorkspaceSidebar"; import { WorkspaceView } from "./components/WorkspaceView"; function LoadingSpinner() { @@ -57,9 +62,15 @@ export function MainScreen() { const currentView = useCurrentView(); const openSettings = useOpenSettings(); const { toggleSidebar } = useSidebarStore(); + const { toggleOpen: toggleWorkspaceSidebar } = useWorkspaceSidebarStore(); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); + + // Navigation style setting + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const effectiveNavigationStyle = navigationStyle ?? DEFAULT_NAVIGATION_STYLE; + const isSidebarMode = effectiveNavigationStyle === "sidebar"; const { data: activeWorkspace, isLoading: isWorkspaceLoading, @@ -111,6 +122,10 @@ export function MainScreen() { [toggleSidebar, isWorkspaceView], ); + useHotkeys(HOTKEYS.TOGGLE_WORKSPACE_SIDEBAR.keys, () => { + if (isSidebarMode) toggleWorkspaceSidebar(); + }, [toggleWorkspaceSidebar, isSidebarMode]); + /** * Resolves the target pane for split operations. * If the focused pane is desynced from layout (e.g., was removed), @@ -336,8 +351,11 @@ export function MainScreen() { ) : (
- -
{renderContent()}
+ +
+ {isSidebarMode && } + {renderContent()} +
)} diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index 886d0523e00..8d3726bd2c1 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -6,4 +6,5 @@ export * from "./ringtone"; export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; +export * from "./workspace-sidebar-state"; export * from "./workspace-view-mode"; diff --git a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts new file mode 100644 index 00000000000..5093f7d3997 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts @@ -0,0 +1,97 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +const DEFAULT_WORKSPACE_SIDEBAR_WIDTH = 280; +export const MIN_WORKSPACE_SIDEBAR_WIDTH = 220; +export const MAX_WORKSPACE_SIDEBAR_WIDTH = 400; + +interface WorkspaceSidebarState { + isOpen: boolean; + width: number; + lastOpenWidth: number; + // Use string[] instead of Set for JSON serialization with Zustand persist + collapsedProjectIds: string[]; + isResizing: boolean; + + toggleOpen: () => void; + setOpen: (open: boolean) => void; + setWidth: (width: number) => void; + setIsResizing: (isResizing: boolean) => void; + toggleProjectCollapsed: (projectId: string) => void; + isProjectCollapsed: (projectId: string) => boolean; +} + +export const useWorkspaceSidebarStore = create()( + devtools( + persist( + (set, get) => ({ + isOpen: true, + width: DEFAULT_WORKSPACE_SIDEBAR_WIDTH, + lastOpenWidth: DEFAULT_WORKSPACE_SIDEBAR_WIDTH, + collapsedProjectIds: [], + isResizing: false, + + toggleOpen: () => { + const { isOpen, lastOpenWidth } = get(); + if (isOpen) { + set({ isOpen: false, width: 0 }); + } else { + set({ + isOpen: true, + width: lastOpenWidth, + }); + } + }, + + setOpen: (open) => { + const { lastOpenWidth } = get(); + set({ + isOpen: open, + width: open ? lastOpenWidth : 0, + }); + }, + + setWidth: (width) => { + const clampedWidth = Math.max( + MIN_WORKSPACE_SIDEBAR_WIDTH, + Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, width), + ); + + if (width > 0) { + set({ + width: clampedWidth, + lastOpenWidth: clampedWidth, + isOpen: true, + }); + } else { + set({ + width: 0, + isOpen: false, + }); + } + }, + + setIsResizing: (isResizing) => { + set({ isResizing }); + }, + + toggleProjectCollapsed: (projectId) => { + set((state) => ({ + collapsedProjectIds: state.collapsedProjectIds.includes(projectId) + ? state.collapsedProjectIds.filter((id) => id !== projectId) + : [...state.collapsedProjectIds, projectId], + })); + }, + + isProjectCollapsed: (projectId) => { + return get().collapsedProjectIds.includes(projectId); + }, + }), + { + name: "workspace-sidebar-store", + version: 1, + }, + ), + { name: "WorkspaceSidebarStore" }, + ), +); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 1ec904ae1fc..35c6207de41 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -47,3 +47,4 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; +export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index c4e38007bbe..b650d06d791 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -423,7 +423,12 @@ export const HOTKEYS = { // Layout TOGGLE_SIDEBAR: defineHotkey({ keys: "meta+b", - label: "Toggle Sidebar", + label: "Toggle Files Sidebar", + category: "Layout", + }), + TOGGLE_WORKSPACE_SIDEBAR: hotkey({ + keys: "meta+shift+b", + label: "Toggle Workspaces Sidebar", category: "Layout", }), SPLIT_RIGHT: defineHotkey({ diff --git a/packages/local-db/drizzle/0005_add_navigation_style.sql b/packages/local-db/drizzle/0005_add_navigation_style.sql new file mode 100644 index 00000000000..c3c175a0327 --- /dev/null +++ b/packages/local-db/drizzle/0005_add_navigation_style.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `navigation_style` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0005_snapshot.json b/packages/local-db/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000000..14c02c328fd --- /dev/null +++ b/packages/local-db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,984 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ac200b80-657f-4cd7-b338-2d6adeb925e7", + "prevId": "bb9f9f85-bcbb-4003-b20f-4c172a1c6fc8", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "navigation_style": { + "name": "navigation_style", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 65dc59e762b..ee8d7e16496 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1767166138761, "tag": "0004_add_terminal_link_behavior_setting", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1767166547886, + "tag": "0005_add_navigation_style", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 0f98d17ada9..c708a32964f 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -112,6 +112,11 @@ export const workspaces = sqliteTable( export type InsertWorkspace = typeof workspaces.$inferInsert; export type SelectWorkspace = typeof workspaces.$inferSelect; +/** + * Navigation style for workspace display + */ +export type NavigationStyle = "top-bar" | "sidebar"; + /** * Settings table - single row with typed columns */ @@ -131,6 +136,7 @@ export const settings = sqliteTable("settings", { terminalLinkBehavior: text( "terminal_link_behavior", ).$type(), + navigationStyle: text("navigation_style").$type(), }); export type InsertSettings = typeof settings.$inferInsert; From 31e7ef32b0afb49ecb229d0dbc9f333e3910248d Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 10:32:08 +0200 Subject: [PATCH 16/64] fix(desktop): P0 CreateWorkspaceButton in sidebar mode + P1 race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Add CreateWorkspaceButton to TopBar when in sidebar mode - Button was previously only rendered via WorkspaceTabs - Now renders in top-right section when navigationStyle is sidebar P1: Fix race condition in createBranchWorkspace - Add unique partial index on (projectId) WHERE type='branch' - Use INSERT ON CONFLICT DO NOTHING to handle concurrent calls - If conflict, fetch the existing workspace instead of failing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/trpc/routers/workspaces/workspaces.ts | 87 +++++++++------ .../screens/main/components/TopBar/index.tsx | 2 + .../TabsContent/GroupStrip/GroupStrip.tsx | 104 ++++++++++++++++-- ...0006_add_unique_branch_workspace_index.sql | 1 + packages/local-db/drizzle/meta/_journal.json | 7 ++ 5 files changed, 159 insertions(+), 42 deletions(-) create mode 100644 packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 670b8e41b11..ee0aa14c8a6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -319,8 +319,9 @@ export const createWorkspacesRouter = () => { .run(); } - // Insert new workspace - const workspace = localDb + // Insert new workspace with conflict handling for race conditions + // The unique partial index (projectId WHERE type='branch') prevents duplicates + const insertResult = localDb .insert(workspaces) .values({ projectId: input.projectId, @@ -329,8 +330,30 @@ export const createWorkspacesRouter = () => { name: branch, tabOrder: 0, }) + .onConflictDoNothing() .returning() - .get(); + .all(); + + // If insert returned nothing, another concurrent call won the race + // Fetch the existing workspace instead + const workspace = + insertResult[0] ?? + localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.type, "branch"), + ), + ) + .get(); + + if (!workspace) { + throw new Error("Failed to create or find branch workspace"); + } + + const wasExisting = insertResult.length === 0; // Update settings localDb @@ -342,41 +365,43 @@ export const createWorkspacesRouter = () => { }) .run(); - // Update project - const activeProjects = localDb - .select() - .from(projects) - .where(isNotNull(projects.tabOrder)) - .all(); - const maxProjectTabOrder = - activeProjects.length > 0 - ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) - : -1; + // Update project (only if we actually inserted a new workspace) + if (!wasExisting) { + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + const maxProjectTabOrder = + activeProjects.length > 0 + ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) + : -1; - localDb - .update(projects) - .set({ - lastOpenedAt: Date.now(), - tabOrder: - project.tabOrder === null - ? maxProjectTabOrder + 1 - : project.tabOrder, - }) - .where(eq(projects.id, input.projectId)) - .run(); + localDb + .update(projects) + .set({ + lastOpenedAt: Date.now(), + tabOrder: + project.tabOrder === null + ? maxProjectTabOrder + 1 + : project.tabOrder, + }) + .where(eq(projects.id, input.projectId)) + .run(); - track("workspace_opened", { - workspace_id: workspace.id, - project_id: project.id, - type: "branch", - was_existing: false, - }); + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "branch", + was_existing: false, + }); + } return { workspace, worktreePath: project.mainRepoPath, projectId: project.id, - wasExisting: false, + wasExisting, }; }), diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 2441e996563..aec8802e03b 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -5,6 +5,7 @@ import { SidebarControl } from "./SidebarControl"; import { WindowControls } from "./WindowControls"; import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; import { WorkspacesTabs } from "./WorkspaceTabs"; +import { CreateWorkspaceButton } from "./WorkspaceTabs/CreateWorkspaceButton"; interface TopBarProps { navigationStyle?: NavigationStyle; @@ -45,6 +46,7 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { )}
+ {isSidebarMode && } {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 28b4abd9996..61b7b7f8e3f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -1,5 +1,6 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useMemo } from "react"; import { HiMiniPlus, HiMiniXMark } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; @@ -11,6 +12,7 @@ interface GroupItemProps { tab: Tab; isActive: boolean; needsAttention: boolean; + isSidebarMode: boolean; onSelect: () => void; onClose: () => void; } @@ -19,11 +21,71 @@ function GroupItem({ tab, isActive, needsAttention, + isSidebarMode, onSelect, onClose, }: GroupItemProps) { const displayName = getTabDisplayName(tab); + if (isSidebarMode) { + // Sidebar mode: browser-tab style matching workspace tabs + return ( +
+ + + + + + {displayName} + + + + + + + + Close group + + +
+ ); + } + + // Top-bar mode: original pill style return (
@@ -65,6 +127,8 @@ function GroupItem({ export function GroupStrip() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const isSidebarMode = navigationStyle === "sidebar"; const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); @@ -114,18 +178,36 @@ export function GroupStrip() { }; return ( -
+
{tabs.length > 0 && ( -
+
{tabs.map((tab) => ( - handleSelectGroup(tab.id)} - onClose={() => handleCloseGroup(tab.id)} - /> + className={isSidebarMode ? "h-full shrink-0" : undefined} + style={isSidebarMode ? { width: "120px" } : undefined} + > + handleSelectGroup(tab.id)} + onClose={() => handleCloseGroup(tab.id)} + /> +
))}
)} @@ -134,10 +216,10 @@ export function GroupStrip() { diff --git a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql new file mode 100644 index 00000000000..b38893a9abb --- /dev/null +++ b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS `workspaces_unique_branch_per_project` ON `workspaces` (`project_id`) WHERE `type` = 'branch'; diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index ee8d7e16496..d72a8fe1e80 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1767166547886, "tag": "0005_add_navigation_style", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1767230000000, + "tag": "0006_add_unique_branch_workspace_index", + "breakpoints": true } ] } \ No newline at end of file From 08467fc43e82786dc04f9ad1cf3da7e83b612390 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 11:36:31 +0200 Subject: [PATCH 17/64] feat(desktop): workspace sidebar 1-1 feature parity with top-bar tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkspaceListItem now has full feature parity: - Close/delete button with confirmation dialog - Context menu (Rename, Open in Finder) - Hover card with PR details, checks, reviews - Needs attention indicator (red pulse) - Inline rename (double-click) - Drag & drop reordering - BranchSwitcher for branch workspaces Other changes: - Remove CreateWorkspaceButton from TopBar in sidebar mode - Add per-project "Add workspace" dropdown (New Workspace, Quick Create) - Add preSelectedProjectId to modal store for pre-selecting project - Simplify GroupStrip to use consistent browser-tab style 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../NewWorkspaceModal/NewWorkspaceModal.tsx | 13 +- .../screens/main/components/TopBar/index.tsx | 2 - .../ProjectSection/ProjectSection.tsx | 65 ++++ .../WorkspaceListItem/WorkspaceListItem.tsx | 311 ++++++++++++++++-- .../TabsContent/GroupStrip/GroupStrip.tsx | 141 +++----- .../renderer/stores/new-workspace-modal.ts | 12 +- 6 files changed, 413 insertions(+), 131 deletions(-) diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 98d08f1af40..17f02c2718b 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -36,6 +36,7 @@ import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, + usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; import { ExistingWorktreesList } from "./components/ExistingWorktreesList"; @@ -57,6 +58,7 @@ type Mode = "existing" | "new"; export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); + const preSelectedProjectId = usePreSelectedProjectId(); const [selectedProjectId, setSelectedProjectId] = useState( null, ); @@ -94,12 +96,15 @@ export function NewWorkspaceModal() { ); }, [branchData?.branches, branchSearch]); - // Auto-select current project when modal opens + // Auto-select project when modal opens (prioritize pre-selected, then current) useEffect(() => { - if (isOpen && currentProjectId && !selectedProjectId) { - setSelectedProjectId(currentProjectId); + if (isOpen && !selectedProjectId) { + const projectToSelect = preSelectedProjectId ?? currentProjectId; + if (projectToSelect) { + setSelectedProjectId(projectToSelect); + } } - }, [isOpen, currentProjectId, selectedProjectId]); + }, [isOpen, currentProjectId, selectedProjectId, preSelectedProjectId]); // Effective base branch - use explicit selection or fall back to default const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? null; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index aec8802e03b..2441e996563 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -5,7 +5,6 @@ import { SidebarControl } from "./SidebarControl"; import { WindowControls } from "./WindowControls"; import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; import { WorkspacesTabs } from "./WorkspaceTabs"; -import { CreateWorkspaceButton } from "./WorkspaceTabs/CreateWorkspaceButton"; interface TopBarProps { navigationStyle?: NavigationStyle; @@ -46,7 +45,6 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { )}
- {isSidebarMode && } {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 41a5e5f9e20..0f52fec1a79 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -1,5 +1,16 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; import { AnimatePresence, motion } from "framer-motion"; +import { useState } from "react"; +import { HiMiniPlus, HiOutlineBolt } from "react-icons/hi2"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { useWorkspaceSidebarStore } from "renderer/stores"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { WorkspaceListItem } from "../WorkspaceListItem"; import { ProjectHeader } from "./ProjectHeader"; @@ -31,11 +42,29 @@ export function ProjectSection({ activeWorkspaceId, shortcutBaseIndex, }: ProjectSectionProps) { + const [dropdownOpen, setDropdownOpen] = useState(false); const { isProjectCollapsed, toggleProjectCollapsed } = useWorkspaceSidebarStore(); + const createWorkspace = useCreateWorkspace(); + const openModal = useOpenNewWorkspaceModal(); const isCollapsed = isProjectCollapsed(projectId); + const handleQuickCreate = () => { + setDropdownOpen(false); + toast.promise(createWorkspace.mutateAsync({ projectId }), { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }); + }; + + const handleNewWorkspace = () => { + setDropdownOpen(false); + openModal(projectId); + }; + return (
))} + + + + + + + + New Workspace + + + + Quick Create + + +
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index a819fbb3413..fc8dbf5e45f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -1,30 +1,79 @@ +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; +import { Input } from "@superset/ui/input"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useState } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { HiMiniXMark } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; +import { + useDeleteWorkspace, + useReorderWorkspaces, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; +import { BranchSwitcher } from "renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher"; +import { DeleteWorkspaceDialog } from "renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog"; +import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; +import { WorkspaceHoverCardContent } from "renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard"; +import { useTabsStore } from "renderer/stores/tabs/store"; import { WorkspaceDiffStats } from "./WorkspaceDiffStats"; import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; +const WORKSPACE_TYPE = "WORKSPACE"; + interface WorkspaceListItemProps { id: string; + projectId: string; + worktreePath: string; name: string; branch: string; type: "worktree" | "branch"; isActive: boolean; + index: number; shortcutIndex?: number; } export function WorkspaceListItem({ id, + projectId, + worktreePath, name, branch, type, isActive, + index, shortcutIndex, }: WorkspaceListItemProps) { + const isBranchWorkspace = type === "branch"; const setActiveWorkspace = useSetActiveWorkspace(); + const reorderWorkspaces = useReorderWorkspaces(); + const deleteWorkspace = useDeleteWorkspace(); const [hasHovered, setHasHovered] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const rename = useWorkspaceRename(id, name); + const tabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const openInFinder = trpc.external.openInFinder.useMutation(); + + // Query to check if workspace can be deleted + const canDeleteQuery = trpc.workspaces.canDelete.useQuery( + { id }, + { enabled: false }, + ); // Lazy-load GitHub status on hover to avoid N+1 queries const { data: githubStatus } = trpc.workspaces.getGitHubStatus.useQuery( @@ -35,8 +84,34 @@ export function WorkspaceListItem({ }, ); + // Check if any pane in tabs belonging to this workspace needs attention + const workspaceTabs = tabs.filter((t) => t.workspaceId === id); + const workspacePaneIds = new Set( + workspaceTabs.flatMap((t) => { + const collectPaneIds = (node: unknown): string[] => { + if (typeof node === "string") return [node]; + if ( + node && + typeof node === "object" && + "first" in node && + "second" in node + ) { + const b = node as { first: unknown; second: unknown }; + return [...collectPaneIds(b.first), ...collectPaneIds(b.second)]; + } + return []; + }; + return collectPaneIds(t.layout); + }), + ); + const needsAttention = Object.values(panes) + .filter((p) => workspacePaneIds.has(p.id)) + .some((p) => p.needsAttention); + const handleClick = () => { - setActiveWorkspace.mutate({ id }); + if (!rename.isRenaming) { + setActiveWorkspace.mutate({ id }); + } }; const handleMouseEnter = () => { @@ -45,20 +120,108 @@ export function WorkspaceListItem({ } }; + const handleOpenInFinder = () => { + if (worktreePath) { + openInFinder.mutate(worktreePath); + } + }; + + const handleDeleteClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; + + try { + const { data: canDeleteData } = await canDeleteQuery.refetch(); + + if (isBranchWorkspace) { + if ( + canDeleteData?.activeTerminalCount && + canDeleteData.activeTerminalCount > 0 + ) { + setShowDeleteDialog(true); + } else { + toast.promise(deleteWorkspace.mutateAsync({ id }), { + loading: `Closing "${name}"...`, + success: `Workspace "${name}" closed`, + error: (error) => + error instanceof Error + ? `Failed to close workspace: ${error.message}` + : "Failed to close workspace", + }); + } + return; + } + + const isEmpty = + canDeleteData?.canDelete && + canDeleteData.activeTerminalCount === 0 && + !canDeleteData.warning && + !canDeleteData.hasChanges && + !canDeleteData.hasUnpushedCommits; + + if (isEmpty) { + toast.promise(deleteWorkspace.mutateAsync({ id }), { + loading: `Deleting "${name}"...`, + success: `Workspace "${name}" deleted`, + error: (error) => + error instanceof Error + ? `Failed to delete workspace: ${error.message}` + : "Failed to delete workspace", + }); + } else { + setShowDeleteDialog(true); + } + } catch { + setShowDeleteDialog(true); + } + }; + + // Drag and drop + const [{ isDragging }, drag] = useDrag( + () => ({ + type: WORKSPACE_TYPE, + item: { id, projectId, index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [id, projectId, index], + ); + + const [, drop] = useDrop({ + accept: WORKSPACE_TYPE, + hover: (item: { id: string; projectId: string; index: number }) => { + if (item.projectId === projectId && item.index !== index) { + reorderWorkspaces.mutate({ + projectId, + fromIndex: item.index, + toIndex: index, + }); + item.index = index; + } + }, + }); + const pr = githubStatus?.pr; const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); - return ( + const content = ( + + + Delete workspace + + + )} ); + + // Wrap with context menu and hover card + if (isBranchWorkspace) { + return ( + <> + + {content} + + + Open in Finder + + + + + + ); + } + + return ( + <> + + + + {content} + + + + Rename + + + + Open in Finder + + + + + + + + + + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 61b7b7f8e3f..e6f271b53a4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -12,7 +12,6 @@ interface GroupItemProps { tab: Tab; isActive: boolean; needsAttention: boolean; - isSidebarMode: boolean; onSelect: () => void; onClose: () => void; } @@ -21,88 +20,32 @@ function GroupItem({ tab, isActive, needsAttention, - isSidebarMode, onSelect, onClose, }: GroupItemProps) { const displayName = getTabDisplayName(tab); - if (isSidebarMode) { - // Sidebar mode: browser-tab style matching workspace tabs - return ( -
- - - - - - {displayName} - - - - - - - - Close group - - -
- ); - } - - // Top-bar mode: original pill style return ( -
+
@@ -111,24 +54,35 @@ function GroupItem({ {displayName} - + + + + + + Close group + +
); } export function GroupStrip() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - const isSidebarMode = navigationStyle === "sidebar"; const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); @@ -178,32 +132,19 @@ export function GroupStrip() { }; return ( -
+
{tabs.length > 0 && ( -
+
{tabs.map((tab) => (
handleSelectGroup(tab.id)} onClose={() => handleCloseGroup(tab.id)} /> @@ -216,10 +157,10 @@ export function GroupStrip() { diff --git a/apps/desktop/src/renderer/stores/new-workspace-modal.ts b/apps/desktop/src/renderer/stores/new-workspace-modal.ts index 0890c7797e4..38b18916b32 100644 --- a/apps/desktop/src/renderer/stores/new-workspace-modal.ts +++ b/apps/desktop/src/renderer/stores/new-workspace-modal.ts @@ -3,7 +3,8 @@ import { devtools } from "zustand/middleware"; interface NewWorkspaceModalState { isOpen: boolean; - openModal: () => void; + preSelectedProjectId: string | null; + openModal: (projectId?: string) => void; closeModal: () => void; } @@ -11,13 +12,14 @@ export const useNewWorkspaceModalStore = create()( devtools( (set) => ({ isOpen: false, + preSelectedProjectId: null, - openModal: () => { - set({ isOpen: true }); + openModal: (projectId?: string) => { + set({ isOpen: true, preSelectedProjectId: projectId ?? null }); }, closeModal: () => { - set({ isOpen: false }); + set({ isOpen: false, preSelectedProjectId: null }); }, }), { name: "NewWorkspaceModalStore" }, @@ -31,3 +33,5 @@ export const useOpenNewWorkspaceModal = () => useNewWorkspaceModalStore((state) => state.openModal); export const useCloseNewWorkspaceModal = () => useNewWorkspaceModalStore((state) => state.closeModal); +export const usePreSelectedProjectId = () => + useNewWorkspaceModalStore((state) => state.preSelectedProjectId); From 181ccb73263777675d3a546dd8045d07a9202fd4 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 11:50:56 +0200 Subject: [PATCH 18/64] refactor(desktop): extract shared utilities for workspace components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract useWorkspaceDeleteHandler hook for shared delete logic - Use existing extractPaneIdsFromLayout from tabs/utils instead of inline collectPaneIds - Add named constants for magic numbers (staleTime, delays, shortcut index) - Reduces code duplication between WorkspaceItem and WorkspaceListItem 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../renderer/react-query/workspaces/index.ts | 1 + .../workspaces/useWorkspaceDeleteHandler.ts | 101 +++++++++++++++++ .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 100 ++--------------- .../TopBar/WorkspaceTabs/constants.ts | 9 ++ .../WorkspaceListItem/WorkspaceListItem.tsx | 105 ++++-------------- .../WorkspaceListItem/constants.ts | 15 +++ 6 files changed, 158 insertions(+), 173 deletions(-) create mode 100644 apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/constants.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 60a9c29b75d..438ba849573 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -6,3 +6,4 @@ export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; +export { useWorkspaceDeleteHandler } from "./useWorkspaceDeleteHandler"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts new file mode 100644 index 00000000000..9acf1b37401 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -0,0 +1,101 @@ +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useDeleteWorkspace } from "./useDeleteWorkspace"; + +interface UseWorkspaceDeleteHandlerParams { + id: string; + name: string; + type: "worktree" | "branch"; +} + +interface UseWorkspaceDeleteHandlerResult { + /** Whether the delete dialog should be shown */ + showDeleteDialog: boolean; + /** Set whether the delete dialog should be shown */ + setShowDeleteDialog: (show: boolean) => void; + /** Handle delete click - checks conditions and either deletes directly or shows dialog */ + handleDeleteClick: (e?: React.MouseEvent) => Promise; + /** Whether a delete operation is pending */ + isPending: boolean; +} + +/** + * Shared hook for workspace delete logic. + * Handles the decision of whether to show confirmation dialog or delete directly. + * + * For branch workspaces: Shows dialog only if there are active terminals + * For worktree workspaces: Shows dialog if there are changes, unpushed commits, or terminals + */ +export function useWorkspaceDeleteHandler({ + id, + name, + type, +}: UseWorkspaceDeleteHandlerParams): UseWorkspaceDeleteHandlerResult { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const deleteWorkspace = useDeleteWorkspace(); + const isBranchWorkspace = type === "branch"; + + const canDeleteQuery = trpc.workspaces.canDelete.useQuery( + { id }, + { enabled: false }, + ); + + const handleDeleteClick = async (e?: React.MouseEvent) => { + e?.stopPropagation(); + + if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; + + try { + const { data: canDeleteData } = await canDeleteQuery.refetch(); + + if (isBranchWorkspace) { + if ( + canDeleteData?.activeTerminalCount && + canDeleteData.activeTerminalCount > 0 + ) { + setShowDeleteDialog(true); + } else { + toast.promise(deleteWorkspace.mutateAsync({ id }), { + loading: `Closing "${name}"...`, + success: `Workspace "${name}" closed`, + error: (error) => + error instanceof Error + ? `Failed to close workspace: ${error.message}` + : "Failed to close workspace", + }); + } + return; + } + + const isEmpty = + canDeleteData?.canDelete && + canDeleteData.activeTerminalCount === 0 && + !canDeleteData.warning && + !canDeleteData.hasChanges && + !canDeleteData.hasUnpushedCommits; + + if (isEmpty) { + toast.promise(deleteWorkspace.mutateAsync({ id }), { + loading: `Deleting "${name}"...`, + success: `Workspace "${name}" deleted`, + error: (error) => + error instanceof Error + ? `Failed to delete workspace: ${error.message}` + : "Failed to delete workspace", + }); + } else { + setShowDeleteDialog(true); + } + } catch { + setShowDeleteDialog(true); + } + }; + + return { + showDeleteDialog, + setShowDeleteDialog, + handleDeleteClick, + isPending: deleteWorkspace.isPending || canDeleteQuery.isFetching, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 089d3487e88..3187e763dc1 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -1,21 +1,20 @@ import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; -import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; import { - useDeleteWorkspace, useReorderWorkspaces, useSetActiveWorkspace, + useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; import { useCloseSettings } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; import { BranchSwitcher } from "./BranchSwitcher"; +import { DELETE_TOOLTIP_DELAY, WORKSPACE_TOOLTIP_DELAY } from "./constants"; import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; import { useWorkspaceRename } from "./useWorkspaceRename"; import { WorkspaceItemContextMenu } from "./WorkspaceItemContextMenu"; @@ -52,100 +51,19 @@ export function WorkspaceItem({ const isBranchWorkspace = workspaceType === "branch"; const setActive = useSetActiveWorkspace(); const reorderWorkspaces = useReorderWorkspaces(); - const deleteWorkspace = useDeleteWorkspace(); const closeSettings = useCloseSettings(); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); const rename = useWorkspaceRename(id, title); - // Query to check if workspace is empty - only enabled when needed - const canDeleteQuery = trpc.workspaces.canDelete.useQuery( - { id }, - { enabled: false }, - ); - - const handleDeleteClick = async () => { - // Prevent double-clicks and race conditions - if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; - - try { - // Always fetch fresh data before deciding - const { data: canDeleteData } = await canDeleteQuery.refetch(); - - // For branch workspaces, only show dialog if there are active terminals - // (no destructive action - branch stays in repo) - if (isBranchWorkspace) { - if ( - canDeleteData?.activeTerminalCount && - canDeleteData.activeTerminalCount > 0 - ) { - setShowDeleteDialog(true); - } else { - // Close directly without confirmation - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Closing "${title}"...`, - success: `Workspace "${title}" closed`, - error: (error) => - error instanceof Error - ? `Failed to close workspace: ${error.message}` - : "Failed to close workspace", - }); - } - return; - } - - // For worktree workspaces, check all conditions - const isEmpty = - canDeleteData?.canDelete && - canDeleteData.activeTerminalCount === 0 && - !canDeleteData.warning && - !canDeleteData.hasChanges && - !canDeleteData.hasUnpushedCommits; - - if (isEmpty) { - // Delete directly without confirmation - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Deleting "${title}"...`, - success: `Workspace "${title}" deleted`, - error: (error) => - error instanceof Error - ? `Failed to delete workspace: ${error.message}` - : "Failed to delete workspace", - }); - } else { - // Show confirmation dialog - setShowDeleteDialog(true); - } - } catch { - // On error checking status, show dialog for user to decide - setShowDeleteDialog(true); - } - }; + // Shared delete logic + const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = + useWorkspaceDeleteHandler({ id, name: title, type: workspaceType }); // Check if any pane in tabs belonging to this workspace needs attention const workspaceTabs = tabs.filter((t) => t.workspaceId === id); const workspacePaneIds = new Set( - workspaceTabs.flatMap((t) => { - // Extract pane IDs from the layout (which is a MosaicNode) - const collectPaneIds = (node: unknown): string[] => { - if (typeof node === "string") return [node]; - if ( - node && - typeof node === "object" && - "first" in node && - "second" in node - ) { - const branch = node as { first: unknown; second: unknown }; - return [ - ...collectPaneIds(branch.first), - ...collectPaneIds(branch.second), - ]; - } - return []; - }; - return collectPaneIds(t.layout); - }), + workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); const needsAttention = Object.values(panes) .filter((p) => workspacePaneIds.has(p.id)) @@ -230,7 +148,7 @@ export function WorkspaceItem({ /> ) : isBranchWorkspace ? (
- +
@@ -292,7 +210,7 @@ export function WorkspaceItem({ {/* Only show close button for worktree workspaces */} {!isBranchWorkspace && ( - +
{isSidebarMode ? ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx index 526bca55e10..97bf9abdda1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx @@ -1,17 +1,26 @@ +import type { NavigationStyle } from "@superset/local-db"; +import { SidebarControl } from "../../SidebarControl"; import { ViewModeToggle } from "./components/ViewModeToggle"; import { WorkspaceActionBarLeft } from "./components/WorkspaceActionBarLeft"; import { WorkspaceActionBarRight } from "./components/WorkspaceActionBarRight"; interface WorkspaceActionBarProps { worktreePath: string | undefined; + navigationStyle?: NavigationStyle; } -export function WorkspaceActionBar({ worktreePath }: WorkspaceActionBarProps) { +export function WorkspaceActionBar({ + worktreePath, + navigationStyle = "top-bar", +}: WorkspaceActionBarProps) { if (!worktreePath) return null; + const isSidebarMode = navigationStyle === "sidebar"; + return (
+ {isSidebarMode && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index a1d61bf3e53..3c44c7f0a20 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,15 +1,19 @@ import { useMemo } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; -import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; +import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; +import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; import { WorkspaceActionBar } from "./WorkspaceActionBar"; export function WorkspaceView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const effectiveNavigationStyle = navigationStyle ?? DEFAULT_NAVIGATION_STYLE; const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); @@ -52,124 +56,87 @@ export function WorkspaceView() { : "workbench"; // Tab management shortcuts - useAppHotkey( - "NEW_TERMINAL", - () => { - if (activeWorkspaceId) { - // If in Review mode, switch to Workbench first - if (viewMode === "review") { - setWorkspaceViewMode(activeWorkspaceId, "workbench"); - } - addTab(activeWorkspaceId); + useHotkeys(HOTKEYS.NEW_TERMINAL.keys, () => { + if (activeWorkspaceId) { + // If in Review mode, switch to Workbench first + if (viewMode === "review") { + setWorkspaceViewMode(activeWorkspaceId, "workbench"); } - }, - undefined, - [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode], - ); + addTab(activeWorkspaceId); + } + }, [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode]); - useAppHotkey( - "CLOSE_TERMINAL", - () => { - // Close focused pane (which may close the tab if it's the last pane) - if (focusedPaneId) { - removePane(focusedPaneId); - } - }, - undefined, - [focusedPaneId, removePane], - ); + useHotkeys(HOTKEYS.CLOSE_TERMINAL.keys, () => { + // Close focused pane (which may close the tab if it's the last pane) + if (focusedPaneId) { + removePane(focusedPaneId); + } + }, [focusedPaneId, removePane]); - // Switch between tabs (configurable shortcut) - useAppHotkey( - "PREV_TERMINAL", - () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index > 0) { - setActiveTab(activeWorkspaceId, tabs[index - 1].id); - } - }, - undefined, - [activeWorkspaceId, activeTabId, tabs, setActiveTab], - ); + // Switch between tabs (⌘+Up/Down) + useHotkeys(HOTKEYS.PREV_TERMINAL.keys, () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index > 0) { + setActiveTab(activeWorkspaceId, tabs[index - 1].id); + } + }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); - useAppHotkey( - "NEXT_TERMINAL", - () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index < tabs.length - 1) { - setActiveTab(activeWorkspaceId, tabs[index + 1].id); - } - }, - undefined, - [activeWorkspaceId, activeTabId, tabs, setActiveTab], - ); + useHotkeys(HOTKEYS.NEXT_TERMINAL.keys, () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index < tabs.length - 1) { + setActiveTab(activeWorkspaceId, tabs[index + 1].id); + } + }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); - // Switch between panes within a tab (configurable shortcut) - useAppHotkey( - "PREV_PANE", - () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); - if (prevPaneId) { - setFocusedPane(activeTabId, prevPaneId); - } - }, - undefined, - [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], - ); + // Switch between panes within a tab (⌘+⌥+Left/Right) + useHotkeys(HOTKEYS.PREV_PANE.keys, () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); + if (prevPaneId) { + setFocusedPane(activeTabId, prevPaneId); + } + }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); - useAppHotkey( - "NEXT_PANE", - () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); - if (nextPaneId) { - setFocusedPane(activeTabId, nextPaneId); - } - }, - undefined, - [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], - ); + useHotkeys(HOTKEYS.NEXT_PANE.keys, () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); + if (nextPaneId) { + setFocusedPane(activeTabId, nextPaneId); + } + }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); // Open in last used app shortcut const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); const openInApp = trpc.external.openInApp.useMutation(); - useAppHotkey( - "OPEN_IN_APP", - () => { - if (activeWorkspace?.worktreePath) { - openInApp.mutate({ - path: activeWorkspace.worktreePath, - app: lastUsedApp, - }); - } - }, - undefined, - [activeWorkspace?.worktreePath, lastUsedApp], - ); + useHotkeys("meta+o", () => { + if (activeWorkspace?.worktreePath) { + openInApp.mutate({ + path: activeWorkspace.worktreePath, + app: lastUsedApp, + }); + } + }, [activeWorkspace?.worktreePath, lastUsedApp]); // Copy path shortcut const copyPath = trpc.external.copyPath.useMutation(); - useAppHotkey( - "COPY_PATH", - () => { - if (activeWorkspace?.worktreePath) { - copyPath.mutate(activeWorkspace.worktreePath); - } - }, - undefined, - [activeWorkspace?.worktreePath], - ); + useHotkeys("meta+shift+c", () => { + if (activeWorkspace?.worktreePath) { + copyPath.mutate(activeWorkspace.worktreePath); + } + }, [activeWorkspace?.worktreePath]); return (
- +
From 275104cd2339d1d34aae517dad5a473b9c81230e Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 12:13:53 +0200 Subject: [PATCH 20/64] fix(desktop): prevent TopBar overlap with Mac traffic lights during load Default to Mac layout (80px padding) while platform query is loading, since undefined === 'darwin' evaluates to false, causing 16px padding to be used initially on Mac before the query resolves. --- .../src/renderer/screens/main/components/TopBar/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 89a29bea1ea..fe809d43385 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -13,7 +13,8 @@ interface TopBarProps { export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { const { data: platform } = trpc.window.getPlatform.useQuery(); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const isMac = platform === "darwin"; + // Default to Mac layout while loading to avoid overlap with traffic lights + const isMac = platform === undefined || platform === "darwin"; const isSidebarMode = navigationStyle === "sidebar"; return ( From 6abe0533569adba8b0374609e3c7cfbca0e89865 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 12:15:09 +0200 Subject: [PATCH 21/64] fix(desktop): increase Mac traffic light padding for better spacing --- .../src/renderer/screens/main/components/TopBar/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index fe809d43385..f58e33f967f 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -22,7 +22,7 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) {
{isSidebarMode && } From 121e9fd295c9e5d79de4086c2806b43890ebf263 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 13:00:19 +0200 Subject: [PATCH 22/64] fix(desktop): address PR review feedback for changes security and terminal links P0: Fix branch workspace support in assertRegisteredWorktree - Extended validation to check both worktrees.path AND projects.mainRepoPath - Branch workspaces use mainRepoPath which wasn't being validated P1: Fix terminal file-viewer links for absolute paths - Normalize absolute paths to worktree-relative before opening file viewer - File viewer expects relative paths but terminal links can be absolute P2: Fix misleading security comments - Removed claims about symlink checks that aren't implemented - Comments now accurately describe worktree registration + path traversal validation --- .../lib/trpc/routers/changes/file-contents.ts | 2 +- .../changes/security/path-validation.ts | 41 ++++++++++++++----- .../src/lib/trpc/routers/changes/staging.ts | 2 +- .../TabsContent/Terminal/Terminal.tsx | 15 ++++++- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index b0edb575149..dc4aba8f4e5 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -84,7 +84,7 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // secureFs.writeFile handles all validation including symlink checks + // secureFs.writeFile validates worktree registration and path traversal await secureFs.writeFile( input.worktreePath, input.filePath, diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts index 72292859b02..9d6ad55706b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -1,5 +1,5 @@ import { isAbsolute, normalize, resolve, sep } from "node:path"; -import { worktrees } from "@superset/local-db"; +import { projects, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; @@ -53,28 +53,47 @@ export class PathValidationError extends Error { } /** - * Validates that a worktree path is registered in localDb. + * Validates that a workspace path is registered in localDb. * This is THE critical security boundary. * - * @throws PathValidationError if worktree is not registered + * Accepts: + * - Worktree paths (from worktrees table) + * - Project mainRepoPath (for branch workspaces that work on the main repo) + * + * @throws PathValidationError if path is not registered */ -export function assertRegisteredWorktree(worktreePath: string): void { - const exists = localDb +export function assertRegisteredWorktree(workspacePath: string): void { + // Check worktrees table first (most common case) + const worktreeExists = localDb .select() .from(worktrees) - .where(eq(worktrees.path, worktreePath)) + .where(eq(worktrees.path, workspacePath)) .get(); - if (!exists) { - throw new PathValidationError( - "Worktree not registered in database", - "UNREGISTERED_WORKTREE", - ); + if (worktreeExists) { + return; } + + // Check projects.mainRepoPath for branch workspaces + const projectExists = localDb + .select() + .from(projects) + .where(eq(projects.mainRepoPath, workspacePath)) + .get(); + + if (projectExists) { + return; + } + + throw new PathValidationError( + "Workspace path not registered in database", + "UNREGISTERED_WORKTREE", + ); } /** * Gets the worktree record if registered. Returns record for updates. + * Only works for actual worktrees, not project mainRepoPath. * * @throws PathValidationError if worktree is not registered */ diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 83c06a489ec..037227fa4f8 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -69,7 +69,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // secureFs.delete validates path and checks for symlink escapes + // secureFs.delete validates worktree registration and path traversal await secureFs.delete(input.worktreePath, input.filePath); return { success: true }; }), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 4c67aba85b0..c8c3de63890 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -80,7 +80,20 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const behavior = terminalLinkBehavior ?? "external-editor"; if (behavior === "file-viewer") { - addFileViewerPane(workspaceId, { filePath: path }); + // Normalize absolute paths to worktree-relative paths for file viewer + // File viewer expects relative paths, but terminal links can be absolute + let filePath = path; + if (workspaceCwd && path.startsWith(workspaceCwd)) { + filePath = path.slice(workspaceCwd.length).replace(/^\//, ""); + } else if (path.startsWith("/")) { + // Absolute path outside workspace - still try to open it + // but warn in console as it may fail validation + console.warn( + "[Terminal] Opening absolute path outside workspace:", + path, + ); + } + addFileViewerPane(workspaceId, { filePath }); } else { trpcClient.external.openFileInEditor .mutate({ From 6f140c91261d41cd9d352eaacc504190900f5470 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 13:25:54 +0200 Subject: [PATCH 23/64] feat(desktop): add 'Mark as Unread' context menu option for workspaces - Add markWorkspaceAsUnread action to tabs store that sets needsAttention=true for all panes in a workspace - Add context menu item with LuEyeOff icon to: - WorkspaceItemContextMenu (top bar tabs) - WorkspaceListItem (sidebar) for both branch and worktree workspaces - Leverages existing needsAttention indicator system (red pulsing dot) - Logs when no panes exist in workspace (empty workspace edge case) --- .../WorkspaceItemContextMenu.tsx | 17 ++++++++++++ .../WorkspaceListItem/WorkspaceListItem.tsx | 17 +++++++++++- .../desktop/src/renderer/stores/tabs/store.ts | 27 +++++++++++++++++++ .../desktop/src/renderer/stores/tabs/types.ts | 1 + 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx index 142b12651dc..e514e552b8f 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -11,7 +11,9 @@ import { HoverCardTrigger, } from "@superset/ui/hover-card"; import type { ReactNode } from "react"; +import { LuEyeOff } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; interface WorkspaceItemContextMenuProps { @@ -34,6 +36,7 @@ export function WorkspaceItemContextMenu({ showHoverCard = true, }: WorkspaceItemContextMenuProps) { const openInFinder = trpc.external.openInFinder.useMutation(); + const markWorkspaceAsUnread = useTabsStore((s) => s.markWorkspaceAsUnread); const handleOpenInFinder = () => { if (worktreePath) { @@ -41,6 +44,10 @@ export function WorkspaceItemContextMenu({ } }; + const handleMarkAsUnread = () => { + markWorkspaceAsUnread(workspaceId); + }; + // For branch workspaces, just show context menu without hover card if (!showHoverCard) { return ( @@ -56,6 +63,11 @@ export function WorkspaceItemContextMenu({ Open in Finder + + + + Mark as Unread + ); @@ -77,6 +89,11 @@ export function WorkspaceItemContextMenu({ Open in Finder + + + + Mark as Unread + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 2a2dc858ccd..4e0407a49a3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -17,7 +17,7 @@ import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; -import { LuGitBranch } from "react-icons/lu"; +import { LuEyeOff, LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useReorderWorkspaces, @@ -71,6 +71,7 @@ export function WorkspaceListItem({ const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); + const markWorkspaceAsUnread = useTabsStore((s) => s.markWorkspaceAsUnread); const openInFinder = trpc.external.openInFinder.useMutation(); // Shared delete logic @@ -113,6 +114,10 @@ export function WorkspaceListItem({ } }; + const handleMarkAsUnread = () => { + markWorkspaceAsUnread(id); + }; + // Drag and drop const [{ isDragging }, drag] = useDrag( () => ({ @@ -265,6 +270,11 @@ export function WorkspaceListItem({ Open in Finder + + + + Mark as Unread + Open in Finder + + + + Mark as Unread + diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index f577db7d00b..4c7a9441d7f 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -543,6 +543,33 @@ export const useTabsStore = create()( })); }, + markWorkspaceAsUnread: (workspaceId) => { + const state = get(); + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId, + ); + const workspacePaneIds = workspaceTabs.flatMap((t) => + extractPaneIdsFromLayout(t.layout), + ); + + if (workspacePaneIds.length === 0) { + console.log( + "[tabs/markUnread] No panes to mark for workspace:", + workspaceId, + ); + return; + } + + const newPanes = { ...state.panes }; + for (const paneId of workspacePaneIds) { + if (newPanes[paneId]) { + newPanes[paneId] = { ...newPanes[paneId], needsAttention: true }; + } + } + + set({ panes: newPanes }); + }, + updatePaneCwd: (paneId, cwd, confirmed) => { set((state) => ({ panes: { diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 03fc45d921b..c5c82fa985c 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -70,6 +70,7 @@ export interface TabsStore extends TabsState { setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; setNeedsAttention: (paneId: string, needsAttention: boolean) => void; + markWorkspaceAsUnread: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, cwd: string | null, From f5213c3f9859d006d432907769180b3dbfb74658 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 13:37:58 +0200 Subject: [PATCH 24/64] fix(desktop): clear workspace attention when clicking workspace Previously, clicking a workspace only set it as active but didn't clear the needsAttention state on panes. This meant 'Mark as Unread' worked but clicking the workspace didn't mark it as read. Added clearWorkspaceAttention action that clears needsAttention for all panes in a workspace, called when clicking workspace in both top bar and sidebar. --- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 4 +++ .../WorkspaceListItem/WorkspaceListItem.tsx | 4 +++ .../desktop/src/renderer/stores/tabs/store.ts | 27 +++++++++++++++++++ .../desktop/src/renderer/stores/tabs/types.ts | 1 + 4 files changed, 36 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 3187e763dc1..2f91dbcbb9c 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -54,6 +54,9 @@ export function WorkspaceItem({ const closeSettings = useCloseSettings(); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); + const clearWorkspaceAttention = useTabsStore( + (s) => s.clearWorkspaceAttention, + ); const rename = useWorkspaceRename(id, title); // Shared delete logic @@ -119,6 +122,7 @@ export function WorkspaceItem({ if (!rename.isRenaming) { closeSettings(); setActive.mutate({ id }); + clearWorkspaceAttention(id); } }} onDoubleClick={isBranchWorkspace ? undefined : rename.startRename} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 4e0407a49a3..a955e51a65d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -72,6 +72,9 @@ export function WorkspaceListItem({ const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); const markWorkspaceAsUnread = useTabsStore((s) => s.markWorkspaceAsUnread); + const clearWorkspaceAttention = useTabsStore( + (s) => s.clearWorkspaceAttention, + ); const openInFinder = trpc.external.openInFinder.useMutation(); // Shared delete logic @@ -99,6 +102,7 @@ export function WorkspaceListItem({ const handleClick = () => { if (!rename.isRenaming) { setActiveWorkspace.mutate({ id }); + clearWorkspaceAttention(id); } }; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 4c7a9441d7f..9347c4d1b21 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -570,6 +570,33 @@ export const useTabsStore = create()( set({ panes: newPanes }); }, + clearWorkspaceAttention: (workspaceId) => { + const state = get(); + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId, + ); + const workspacePaneIds = workspaceTabs.flatMap((t) => + extractPaneIdsFromLayout(t.layout), + ); + + if (workspacePaneIds.length === 0) { + return; + } + + const newPanes = { ...state.panes }; + let hasChanges = false; + for (const paneId of workspacePaneIds) { + if (newPanes[paneId]?.needsAttention) { + newPanes[paneId] = { ...newPanes[paneId], needsAttention: false }; + hasChanges = true; + } + } + + if (hasChanges) { + set({ panes: newPanes }); + } + }, + updatePaneCwd: (paneId, cwd, confirmed) => { set((state) => ({ panes: { diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index c5c82fa985c..d6f43ab6e00 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -71,6 +71,7 @@ export interface TabsStore extends TabsState { markPaneAsUsed: (paneId: string) => void; setNeedsAttention: (paneId: string, needsAttention: boolean) => void; markWorkspaceAsUnread: (workspaceId: string) => void; + clearWorkspaceAttention: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, cwd: string | null, From df02cc1a339983e2c29522060924c139983b4421 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 14:27:32 +0200 Subject: [PATCH 25/64] fix(desktop): address second round of PR review feedback P0: Make unique branch workspace migration resilient to existing duplicates - Dedupe existing duplicate branch workspaces before creating unique index - Update settings.last_active_workspace_id if it points to deleted workspace P1: Fix isResizing persistence and store selector usage - Exclude ephemeral isResizing from Zustand persistence via partialize - Use selector pattern in MainScreen to avoid unnecessary re-renders P1: Fix delete handler to show dialog when canDeleteData is undefined - Safe default: show confirmation when query fails P2: Fix path boundary check in terminal file links - Use path.startsWith(cwd + '/') to avoid false prefix matches P2: Add missing drizzle migration snapshot - Created 0006_snapshot.json for consistency --- .../workspaces/useWorkspaceDeleteHandler.ts | 10 +- .../TabsContent/Terminal/Terminal.tsx | 24 +- .../src/renderer/screens/main/index.tsx | 4 +- .../stores/workspace-sidebar-state.ts | 8 + ...0006_add_unique_branch_workspace_index.sql | 32 + .../local-db/drizzle/meta/0006_snapshot.json | 984 ++++++++++++++++++ 6 files changed, 1048 insertions(+), 14 deletions(-) create mode 100644 packages/local-db/drizzle/meta/0006_snapshot.json diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts index 9acf1b37401..b7100f5a753 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -50,9 +50,11 @@ export function useWorkspaceDeleteHandler({ const { data: canDeleteData } = await canDeleteQuery.refetch(); if (isBranchWorkspace) { + // Show dialog if we couldn't get data (safe default) or there are active terminals if ( - canDeleteData?.activeTerminalCount && - canDeleteData.activeTerminalCount > 0 + !canDeleteData || + (canDeleteData.activeTerminalCount && + canDeleteData.activeTerminalCount > 0) ) { setShowDeleteDialog(true); } else { @@ -68,8 +70,10 @@ export function useWorkspaceDeleteHandler({ return; } + // Only skip dialog if we have data confirming it's safe to delete const isEmpty = - canDeleteData?.canDelete && + canDeleteData && + canDeleteData.canDelete && canDeleteData.activeTerminalCount === 0 && !canDeleteData.warning && !canDeleteData.hasChanges && diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index c8c3de63890..0294049054a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -83,15 +83,21 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // Normalize absolute paths to worktree-relative paths for file viewer // File viewer expects relative paths, but terminal links can be absolute let filePath = path; - if (workspaceCwd && path.startsWith(workspaceCwd)) { - filePath = path.slice(workspaceCwd.length).replace(/^\//, ""); - } else if (path.startsWith("/")) { - // Absolute path outside workspace - still try to open it - // but warn in console as it may fail validation - console.warn( - "[Terminal] Opening absolute path outside workspace:", - path, - ); + if (workspaceCwd) { + // Use path boundary check to avoid incorrect prefix stripping + // e.g., /repo vs /repo-other should not match + if (path === workspaceCwd) { + filePath = "."; + } else if (path.startsWith(workspaceCwd + "/")) { + filePath = path.slice(workspaceCwd.length + 1); + } else if (path.startsWith("/")) { + // Absolute path outside workspace - still try to open it + // but warn in console as it may fail validation + console.warn( + "[Terminal] Opening absolute path outside workspace:", + path, + ); + } } addFileViewerPane(workspaceId, { filePath }); } else { diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 0e3ebeb0996..2d5f5543392 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -61,8 +61,8 @@ export function MainScreen() { const currentView = useCurrentView(); const openSettings = useOpenSettings(); - const { toggleSidebar } = useSidebarStore(); - const { toggleOpen: toggleWorkspaceSidebar } = useWorkspaceSidebarStore(); + const toggleSidebar = useSidebarStore((s) => s.toggleSidebar); + const toggleWorkspaceSidebar = useWorkspaceSidebarStore((s) => s.toggleOpen); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); diff --git a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts index 5093f7d3997..adb12801ebe 100644 --- a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts @@ -90,6 +90,14 @@ export const useWorkspaceSidebarStore = create()( { name: "workspace-sidebar-store", version: 1, + // Exclude ephemeral state from persistence + partialize: (state) => ({ + isOpen: state.isOpen, + width: state.width, + lastOpenWidth: state.lastOpenWidth, + collapsedProjectIds: state.collapsedProjectIds, + // isResizing intentionally excluded - ephemeral UI state + }), }, ), { name: "WorkspaceSidebarStore" }, diff --git a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql index b38893a9abb..769ddfb436c 100644 --- a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql +++ b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql @@ -1 +1,33 @@ +-- Dedupe existing duplicate branch workspaces before creating unique index. +-- Keep the oldest one (smallest id) as the deterministic winner. +-- First, update settings.last_active_workspace_id if it points to a workspace we're about to delete +UPDATE settings +SET last_active_workspace_id = ( + SELECT w1.id FROM workspaces w1 + WHERE w1.type = 'branch' + AND w1.project_id = ( + SELECT w2.project_id FROM workspaces w2 WHERE w2.id = settings.last_active_workspace_id + ) + ORDER BY w1.id ASC + LIMIT 1 +) +WHERE last_active_workspace_id IN ( + SELECT w1.id FROM workspaces w1 + WHERE w1.type = 'branch' + AND EXISTS ( + SELECT 1 FROM workspaces w2 + WHERE w2.type = 'branch' + AND w2.project_id = w1.project_id + AND w2.id < w1.id + ) +); + +-- Delete duplicate branch workspaces, keeping the oldest (smallest id) per project +DELETE FROM workspaces +WHERE type = 'branch' +AND id NOT IN ( + SELECT MIN(id) FROM workspaces WHERE type = 'branch' GROUP BY project_id +); + +-- Now safe to create the unique index CREATE UNIQUE INDEX IF NOT EXISTS `workspaces_unique_branch_per_project` ON `workspaces` (`project_id`) WHERE `type` = 'branch'; diff --git a/packages/local-db/drizzle/meta/0006_snapshot.json b/packages/local-db/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000000..5362480f6e2 --- /dev/null +++ b/packages/local-db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,984 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "prevId": "ac200b80-657f-4cd7-b338-2d6adeb925e7", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "navigation_style": { + "name": "navigation_style", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} From cc2a3acaed4717bec9f03c9aaa2796054dbc0bdc Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 15:19:16 +0200 Subject: [PATCH 26/64] fix(desktop): address third round of PR review feedback P0: Add symlink escape protection for File Viewer writes - Block writes if file's realpath escapes worktree boundary - Add isSymlinkEscaping() method for UI to detect and warn users - Update threat model docs to reflect new symlink protection P1: Fix race condition in createBranchWorkspace ordering - Insert workspace first, then shift others only on success - Losers of the race no longer corrupt tabOrder - Exclude newly inserted workspace from shift operation P2: Fix migration to keep most recently used duplicate - Select survivor by last_opened_at DESC (not arbitrary MIN(id)) - Use id ASC as tiebreaker when last_opened_at is equal --- .../changes/security/path-validation.ts | 24 +++-- .../routers/changes/security/secure-fs.ts | 91 ++++++++++++++++++- .../lib/trpc/routers/workspaces/workspaces.ts | 48 ++++++---- ...0006_add_unique_branch_workspace_index.sql | 23 ++++- 4 files changed, 148 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts index 9d6ad55706b..317994323f3 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -6,15 +6,12 @@ import { localDb } from "main/lib/local-db"; /** * Security model for desktop app filesystem access: * - * THREAT MODEL ASSUMPTION: - * A compromised renderer can already execute arbitrary commands via - * terminal panes. Therefore, filesystem-level symlink protections - * provide no meaningful security boundary—an attacker with renderer - * access can simply run `cat /etc/passwd` in a terminal. - * - * If your deployment exposes the renderer to untrusted content WITHOUT - * terminal access, this model does NOT apply and symlink escape checks - * should be re-enabled. + * THREAT MODEL: + * While a compromised renderer can execute commands via terminal panes, + * the File Viewer presents a distinct threat: malicious repositories can + * contain symlinks that trick users into reading/writing sensitive files + * (e.g., `docs/config.yml` → `~/.bashrc`). Users clicking these links + * don't know they're accessing files outside the repo. * * PRIMARY BOUNDARY: assertRegisteredWorktree() * - Only worktree paths registered in localDb are accessible via tRPC @@ -24,9 +21,9 @@ import { localDb } from "main/lib/local-db"; * - Rejects absolute paths and ".." traversal segments * - Defense in depth against path manipulation * - * NOT IMPLEMENTED (intentional, see threat model above): - * - Symlink escape detection - * - Realpath resolution + * SYMLINK PROTECTION (secure-fs.ts): + * - Writes: Block if realpath escapes worktree (prevents accidental overwrites) + * - Reads: Caller can check isSymlinkEscaping() to warn users */ /** @@ -36,7 +33,8 @@ export type PathValidationErrorCode = | "ABSOLUTE_PATH" | "PATH_TRAVERSAL" | "UNREGISTERED_WORKTREE" - | "INVALID_TARGET"; + | "INVALID_TARGET" + | "SYMLINK_ESCAPE"; /** * Error thrown when path validation fails. diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 2e461dd731a..ed920e2070d 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -1,6 +1,14 @@ import type { Stats } from "node:fs"; -import { lstat, readFile, rm, stat, writeFile } from "node:fs/promises"; import { + lstat, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { + PathValidationError, assertRegisteredWorktree, resolvePathInWorktree, } from "./path-validation"; @@ -11,10 +19,46 @@ import { * Each operation: * 1. Validates worktree is registered (security boundary) * 2. Validates path doesn't escape worktree (defense in depth) - * 3. Performs the filesystem operation + * 3. For writes: validates target is not a symlink escaping worktree + * 4. Performs the filesystem operation * * See path-validation.ts for the full security model and threat assumptions. */ + +/** + * Check if the resolved realpath stays within the worktree boundary. + * Prevents symlink escape attacks where a symlink points outside the worktree. + * + * @throws PathValidationError if realpath escapes worktree + */ +async function assertRealpathInWorktree( + worktreePath: string, + fullPath: string, +): Promise { + try { + const real = await realpath(fullPath); + const worktreeReal = await realpath(worktreePath); + + // Ensure realpath is within worktree (with proper boundary check) + if (!real.startsWith(worktreeReal + "/") && real !== worktreeReal) { + throw new PathValidationError( + "File is a symlink pointing outside the worktree", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + // If realpath fails with ENOENT, file doesn't exist yet - that's OK for writes + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return; + } + // Re-throw PathValidationError + if (error instanceof PathValidationError) { + throw error; + } + // Other errors (permission denied, etc.) - let them propagate + throw error; + } +} export const secureFs = { /** * Read a file within a worktree. @@ -43,6 +87,12 @@ export const secureFs = { /** * Write content to a file within a worktree. + * + * SECURITY: Blocks writes if the file is a symlink pointing outside + * the worktree. This prevents malicious repos from tricking users + * into overwriting sensitive files like ~/.bashrc. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if target escapes worktree */ async writeFile( worktreePath: string, @@ -51,6 +101,10 @@ export const secureFs = { ): Promise { assertRegisteredWorktree(worktreePath); const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block writes through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + await writeFile(fullPath, content, "utf-8"); }, @@ -107,4 +161,37 @@ export const secureFs = { return false; } }, + + /** + * Check if a file is a symlink that points outside the worktree. + * + * Use this to warn users when viewing files that resolve outside + * the worktree boundary (potential malicious repo symlink). + * + * @returns true if the file is a symlink escaping the worktree + */ + async isSymlinkEscaping( + worktreePath: string, + filePath: string, + ): Promise { + try { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Check if it's a symlink first + const stats = await lstat(fullPath); + if (!stats.isSymbolicLink()) { + return false; + } + + // Check if realpath escapes worktree + const real = await realpath(fullPath); + const worktreeReal = await realpath(worktreePath); + + return !real.startsWith(worktreeReal + "/") && real !== worktreeReal; + } catch { + // If we can't determine, assume not escaping (file may not exist) + return false; + } + }, }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index ee0aa14c8a6..fe99a498631 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -7,7 +7,7 @@ import { workspaces, worktrees, } from "@superset/local-db"; -import { and, desc, eq, isNotNull } from "drizzle-orm"; +import { and, desc, eq, isNotNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; @@ -305,22 +305,10 @@ export const createWorkspacesRouter = () => { }; } - // Shift existing workspaces to make room at front - const projectWorkspaces = localDb - .select() - .from(workspaces) - .where(eq(workspaces.projectId, input.projectId)) - .all(); - for (const ws of projectWorkspaces) { - localDb - .update(workspaces) - .set({ tabOrder: ws.tabOrder + 1 }) - .where(eq(workspaces.id, ws.id)) - .run(); - } - - // Insert new workspace with conflict handling for race conditions + // Insert new workspace first with conflict handling for race conditions // The unique partial index (projectId WHERE type='branch') prevents duplicates + // We insert first, then shift - this prevents race conditions where + // concurrent calls both shift before either inserts (causing double shifts) const insertResult = localDb .insert(workspaces) .values({ @@ -334,6 +322,32 @@ export const createWorkspacesRouter = () => { .returning() .all(); + const wasExisting = insertResult.length === 0; + + // Only shift existing workspaces if we successfully inserted + // Losers of the race should NOT shift (they didn't create anything) + if (!wasExisting) { + const newWorkspaceId = insertResult[0].id; + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + // Exclude the workspace we just inserted + not(eq(workspaces.id, newWorkspaceId)), + ), + ) + .all(); + for (const ws of projectWorkspaces) { + localDb + .update(workspaces) + .set({ tabOrder: ws.tabOrder + 1 }) + .where(eq(workspaces.id, ws.id)) + .run(); + } + } + // If insert returned nothing, another concurrent call won the race // Fetch the existing workspace instead const workspace = @@ -353,8 +367,6 @@ export const createWorkspacesRouter = () => { throw new Error("Failed to create or find branch workspace"); } - const wasExisting = insertResult.length === 0; - // Update settings localDb .insert(settings) diff --git a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql index 769ddfb436c..6945d545ce6 100644 --- a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql +++ b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql @@ -1,5 +1,5 @@ -- Dedupe existing duplicate branch workspaces before creating unique index. --- Keep the oldest one (smallest id) as the deterministic winner. +-- Keep the most recently used one (highest last_opened_at), with id ASC as tiebreaker. -- First, update settings.last_active_workspace_id if it points to a workspace we're about to delete UPDATE settings SET last_active_workspace_id = ( @@ -8,7 +8,7 @@ SET last_active_workspace_id = ( AND w1.project_id = ( SELECT w2.project_id FROM workspaces w2 WHERE w2.id = settings.last_active_workspace_id ) - ORDER BY w1.id ASC + ORDER BY w1.last_opened_at DESC NULLS LAST, w1.id ASC LIMIT 1 ) WHERE last_active_workspace_id IN ( @@ -18,15 +18,28 @@ WHERE last_active_workspace_id IN ( SELECT 1 FROM workspaces w2 WHERE w2.type = 'branch' AND w2.project_id = w1.project_id - AND w2.id < w1.id + AND ( + w2.last_opened_at > w1.last_opened_at + OR (w2.last_opened_at = w1.last_opened_at AND w2.id < w1.id) + OR (w2.last_opened_at IS NOT NULL AND w1.last_opened_at IS NULL) + ) ) ); --- Delete duplicate branch workspaces, keeping the oldest (smallest id) per project +-- Delete duplicate branch workspaces, keeping the most recently used per project +-- Survivor selection: highest last_opened_at, then lowest id as tiebreaker DELETE FROM workspaces WHERE type = 'branch' AND id NOT IN ( - SELECT MIN(id) FROM workspaces WHERE type = 'branch' GROUP BY project_id + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY project_id + ORDER BY last_opened_at DESC NULLS LAST, id ASC + ) as rn + FROM workspaces + WHERE type = 'branch' + ) ranked + WHERE rn = 1 ); -- Now safe to create the unique index From 0c072cf895794440280343bc25da36c15ce662a7 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 09:38:16 +0200 Subject: [PATCH 27/64] feat(desktop): merge workspace action bar into top bar Consolidate two navigation bars into a single unified top bar: - Create WorkspaceControls component group in TopBar/ - BranchIndicator: shows current branch (keyboard-focusable) - OpenInMenuButton: compact Open button with dropdown - ViewModeToggleCompact: Workbench/Review toggle (24px height) - Remove WorkspaceActionBar from WorkspaceView entirely - Delete unused WorkspaceActionBar component folder Improvements based on Oracle review: - Optimize Zustand selector to minimize rerenders - Add toast error handling for open/copy mutations - Add path to Open button tooltip for discoverability - Add focus-visible rings for keyboard accessibility - Use text-xs for consistent typography Also includes minor lint auto-fixes (import sorting, template literals) --- .../routers/changes/security/secure-fs.ts | 6 +- .../workspaces/useWorkspaceDeleteHandler.ts | 3 +- .../WorkspaceControls/BranchIndicator.tsx | 29 +++++ .../WorkspaceControls/OpenInMenuButton.tsx} | 102 ++++++++---------- .../ViewModeToggleCompact.tsx} | 38 ++++--- .../WorkspaceControls/WorkspaceControls.tsx | 26 +++++ .../TopBar/WorkspaceControls/index.ts | 1 + .../screens/main/components/TopBar/index.tsx | 12 ++- .../TabsContent/Terminal/Terminal.tsx | 2 +- .../WorkspaceActionBar/WorkspaceActionBar.tsx | 34 ------ .../components/ViewModeToggle/index.ts | 1 - .../WorkspaceActionBarLeft.tsx | 41 ------- .../WorkspaceActionBarLeft/index.ts | 1 - .../WorkspaceActionBarRight/index.ts | 1 - .../WorkspaceView/WorkspaceActionBar/index.ts | 1 - .../main/components/WorkspaceView/index.tsx | 8 -- .../src/renderer/screens/main/index.tsx | 2 +- 17 files changed, 138 insertions(+), 170 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx rename apps/desktop/src/renderer/screens/main/components/{WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx => TopBar/WorkspaceControls/OpenInMenuButton.tsx} (66%) rename apps/desktop/src/renderer/screens/main/components/{WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx => TopBar/WorkspaceControls/ViewModeToggleCompact.tsx} (50%) create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/index.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index ed920e2070d..5c47f6e7c35 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -8,8 +8,8 @@ import { writeFile, } from "node:fs/promises"; import { - PathValidationError, assertRegisteredWorktree, + PathValidationError, resolvePathInWorktree, } from "./path-validation"; @@ -40,7 +40,7 @@ async function assertRealpathInWorktree( const worktreeReal = await realpath(worktreePath); // Ensure realpath is within worktree (with proper boundary check) - if (!real.startsWith(worktreeReal + "/") && real !== worktreeReal) { + if (!real.startsWith(`${worktreeReal}/`) && real !== worktreeReal) { throw new PathValidationError( "File is a symlink pointing outside the worktree", "SYMLINK_ESCAPE", @@ -188,7 +188,7 @@ export const secureFs = { const real = await realpath(fullPath); const worktreeReal = await realpath(worktreePath); - return !real.startsWith(worktreeReal + "/") && real !== worktreeReal; + return !real.startsWith(`${worktreeReal}/`) && real !== worktreeReal; } catch { // If we can't determine, assume not escaping (file may not exist) return false; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts index b7100f5a753..2c88bf1915c 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -72,8 +72,7 @@ export function useWorkspaceDeleteHandler({ // Only skip dialog if we have data confirming it's safe to delete const isEmpty = - canDeleteData && - canDeleteData.canDelete && + canDeleteData?.canDelete && canDeleteData.activeTerminalCount === 0 && !canDeleteData.warning && !canDeleteData.hasChanges && diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx new file mode 100644 index 00000000000..3146293114f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx @@ -0,0 +1,29 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { GoGitBranch } from "react-icons/go"; + +interface BranchIndicatorProps { + branch: string | undefined; +} + +export function BranchIndicator({ branch }: BranchIndicatorProps) { + if (!branch) return null; + + return ( + + + + + + Current branch + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/OpenInMenuButton.tsx similarity index 66% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/OpenInMenuButton.tsx index 222cade48cf..5287546fbee 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/OpenInMenuButton.tsx @@ -10,9 +10,11 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { HiChevronDown } from "react-icons/hi2"; -import { LuArrowUpRight, LuCopy } from "react-icons/lu"; +import { LuCopy } from "react-icons/lu"; import jetbrainsIcon from "renderer/assets/app-icons/jetbrains.svg"; import vscodeIcon from "renderer/assets/app-icons/vscode.svg"; import { @@ -21,117 +23,105 @@ import { JETBRAINS_OPTIONS, VSCODE_OPTIONS, } from "renderer/components/OpenInButton"; -import { shortenHomePath } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; import { useHotkeyText } from "renderer/stores/hotkeys"; -interface FormattedPath { - prefix: string; - worktreeName: string; -} - -function formatWorktreePath( - path: string, - homeDir: string | undefined, -): FormattedPath { - const shortenedPath = shortenHomePath(path, homeDir); - - // Split into prefix and worktree name (last segment) - const lastSlashIndex = shortenedPath.lastIndexOf("/"); - if (lastSlashIndex !== -1) { - return { - prefix: shortenedPath.slice(0, lastSlashIndex + 1), - worktreeName: shortenedPath.slice(lastSlashIndex + 1), - }; - } - - return { prefix: "", worktreeName: shortenedPath }; -} - -interface WorkspaceActionBarRightProps { +interface OpenInMenuButtonProps { worktreePath: string; } -export function WorkspaceActionBarRight({ - worktreePath, -}: WorkspaceActionBarRightProps) { - const { data: homeDir } = trpc.window.getHomeDir.useQuery(); +export function OpenInMenuButton({ worktreePath }: OpenInMenuButtonProps) { const utils = trpc.useUtils(); const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); const openInApp = trpc.external.openInApp.useMutation({ onSuccess: () => utils.settings.getLastUsedApp.invalidate(), + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const copyPath = trpc.external.copyPath.useMutation({ + onSuccess: () => toast.success("Path copied to clipboard"), + onError: (error) => toast.error(`Failed to copy path: ${error.message}`), }); - const copyPath = trpc.external.copyPath.useMutation(); - const formattedPath = formatWorktreePath(worktreePath, homeDir); const currentApp = getAppOption(lastUsedApp); const openInShortcut = useHotkeyText("OPEN_IN_APP"); const copyPathShortcut = useHotkeyText("COPY_PATH"); const showOpenInShortcut = openInShortcut !== "Unassigned"; const showCopyPathShortcut = copyPathShortcut !== "Unassigned"; + const isLoading = openInApp.isPending || copyPath.isPending; const handleOpenInEditor = () => { + if (isLoading) return; openInApp.mutate({ path: worktreePath, app: lastUsedApp }); }; const handleOpenInOtherApp = (appId: ExternalApp) => { + if (isLoading) return; openInApp.mutate({ path: worktreePath, app: appId }); }; const handleCopyPath = () => { + if (isLoading) return; copyPath.mutate(worktreePath); }; const BUTTON_HEIGHT = 24; return ( - <> - {/* Path - clickable to open */} +
+ {/* Main button - opens in last used app */} - - - Open in {currentApp.displayLabel ?? currentApp.label} - - {showOpenInShortcut ? openInShortcut : "—"} - - + +
+ + Open in {currentApp.displayLabel ?? currentApp.label} + {showOpenInShortcut && ( + + {openInShortcut} + + )} + + + {worktreePath} + +
- {/* Open dropdown button */} + {/* Dropdown trigger */} @@ -215,6 +205,6 @@ export function WorkspaceActionBarRight({ - +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/ViewModeToggleCompact.tsx similarity index 50% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/ViewModeToggleCompact.tsx index 00b93eaf6dc..a93f91fb3c6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/ViewModeToggleCompact.tsx @@ -1,39 +1,43 @@ import { cn } from "@superset/ui/utils"; -import { trpc } from "renderer/lib/trpc"; import { useWorkspaceViewModeStore, type WorkspaceViewMode, } from "renderer/stores/workspace-view-mode"; -export function ViewModeToggle() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const workspaceId = activeWorkspace?.id; +interface ViewModeToggleCompactProps { + workspaceId: string; +} - const viewModeByWorkspaceId = useWorkspaceViewModeStore( - (s) => s.viewModeByWorkspaceId, +export function ViewModeToggleCompact({ + workspaceId, +}: ViewModeToggleCompactProps) { + // Select only this workspace's mode to minimize rerenders + const currentMode = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId[workspaceId] ?? "workbench", ); const setWorkspaceViewMode = useWorkspaceViewModeStore( (s) => s.setWorkspaceViewMode, ); - if (!workspaceId) return null; - - const currentMode = viewModeByWorkspaceId[workspaceId] ?? "workbench"; - const handleModeChange = (mode: WorkspaceViewMode) => { setWorkspaceViewMode(workspaceId, mode); }; + const BUTTON_HEIGHT = 24; + return ( -
+
{isSidebarMode ? ( -
+
{activeWorkspace && ( {activeWorkspace.project?.name ?? "Workspace"} @@ -40,12 +41,17 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { )}
) : ( -
+
)} -
+
+ {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 0294049054a..2c73582c9af 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -88,7 +88,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // e.g., /repo vs /repo-other should not match if (path === workspaceCwd) { filePath = "."; - } else if (path.startsWith(workspaceCwd + "/")) { + } else if (path.startsWith(`${workspaceCwd}/`)) { filePath = path.slice(workspaceCwd.length + 1); } else if (path.startsWith("/")) { // Absolute path outside workspace - still try to open it diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx deleted file mode 100644 index 97bf9abdda1..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { NavigationStyle } from "@superset/local-db"; -import { SidebarControl } from "../../SidebarControl"; -import { ViewModeToggle } from "./components/ViewModeToggle"; -import { WorkspaceActionBarLeft } from "./components/WorkspaceActionBarLeft"; -import { WorkspaceActionBarRight } from "./components/WorkspaceActionBarRight"; - -interface WorkspaceActionBarProps { - worktreePath: string | undefined; - navigationStyle?: NavigationStyle; -} - -export function WorkspaceActionBar({ - worktreePath, - navigationStyle = "top-bar", -}: WorkspaceActionBarProps) { - if (!worktreePath) return null; - - const isSidebarMode = navigationStyle === "sidebar"; - - return ( -
-
- {isSidebarMode && } - -
-
- -
-
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts deleted file mode 100644 index 5e69ac17ef8..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ViewModeToggle } from "./ViewModeToggle"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx deleted file mode 100644 index 0db773eb901..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { GoGitBranch } from "react-icons/go"; -import { trpc } from "renderer/lib/trpc"; - -export function WorkspaceActionBarLeft() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const currentBranch = activeWorkspace?.worktree?.branch; - const baseBranch = activeWorkspace?.worktree?.baseBranch; - return ( - <> - {currentBranch && ( - - - - - - {currentBranch} - - - - - Current branch - - - )} - {baseBranch && baseBranch !== currentBranch && ( - - - - from - {baseBranch} - - - - Based on {baseBranch} - - - )} - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts deleted file mode 100644 index 9a42acaa856..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBarLeft } from "./WorkspaceActionBarLeft"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts deleted file mode 100644 index da70bada5b9..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBarRight } from "./WorkspaceActionBarRight"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts deleted file mode 100644 index c8caa3fbcd3..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBar } from "./WorkspaceActionBar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 3c44c7f0a20..b8f8f07bbf2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -4,16 +4,12 @@ import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; -import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; -import { WorkspaceActionBar } from "./WorkspaceActionBar"; export function WorkspaceView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - const effectiveNavigationStyle = navigationStyle ?? DEFAULT_NAVIGATION_STYLE; const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); const activeTabIds = useTabsStore((s) => s.activeTabIds); @@ -133,10 +129,6 @@ export function WorkspaceView() {
-
diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 2d5f5543392..620b56b7463 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -2,8 +2,8 @@ import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { DndProvider } from "react-dnd"; +import { useHotkeys } from "react-hotkeys-hook"; import { HiArrowPath } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { SetupConfigModal } from "renderer/components/SetupConfigModal"; From fd7656ab8044a14ca838cfd79d0eed7ce3f0c59e Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 10:01:01 +0200 Subject: [PATCH 28/64] fix(desktop): always show close/delete dialog for workspaces - Simplify useWorkspaceDeleteHandler to always show dialog instead of auto-deleting clean workspaces - Update tooltip from 'Delete workspace' to 'Close or delete' for clarity - Add isUnread state for workspace unread tracking - Add workspace setUnread mutation and schema migration --- .../lib/trpc/routers/workspaces/workspaces.ts | 35 +- .../workspaces/useSetActiveWorkspace.ts | 33 +- .../workspaces/useWorkspaceDeleteHandler.ts | 89 +- .../TopBar/WorkspaceTabs/WorkspaceGroup.tsx | 2 + .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 16 +- .../WorkspaceItemContextMenu.tsx | 42 +- .../ProjectSection/ProjectSection.tsx | 2 + .../WorkspaceListItem/WorkspaceListItem.tsx | 54 +- .../desktop/src/renderer/stores/tabs/store.ts | 27 - .../desktop/src/renderer/stores/tabs/types.ts | 1 - .../drizzle/0007_add_workspace_is_unread.sql | 1 + .../local-db/drizzle/meta/0007_snapshot.json | 992 ++++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 7 + packages/local-db/src/schema/schema.ts | 1 + 14 files changed, 1151 insertions(+), 151 deletions(-) create mode 100644 packages/local-db/drizzle/0007_add_workspace_is_unread.sql create mode 100644 packages/local-db/drizzle/meta/0007_snapshot.json diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index fe99a498631..4a57d0a0c8c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -583,6 +583,7 @@ export const createWorkspacesRouter = () => { createdAt: number; updatedAt: number; lastOpenedAt: number; + isUnread: boolean; }>; } >(); @@ -612,6 +613,7 @@ export const createWorkspacesRouter = () => { ...workspace, type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", + isUnread: workspace.isUnread ?? false, }); } } @@ -1014,10 +1016,18 @@ export const createWorkspacesRouter = () => { throw new Error(`Workspace ${input.id} not found`); } + // Track if workspace was unread before clearing + const wasUnread = workspace.isUnread ?? false; + const now = Date.now(); localDb .update(workspaces) - .set({ lastOpenedAt: now, updatedAt: now }) + .set({ + lastOpenedAt: now, + updatedAt: now, + // Auto-clear unread state when switching to workspace + isUnread: false, + }) .where(eq(workspaces.id, input.id)) .run(); @@ -1030,7 +1040,7 @@ export const createWorkspacesRouter = () => { }) .run(); - return { success: true }; + return { success: true, wasUnread }; }), reorder: publicProcedure @@ -1368,6 +1378,27 @@ export const createWorkspacesRouter = () => { }; }), + setUnread: publicProcedure + .input(z.object({ id: z.string(), isUnread: z.boolean() })) + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + localDb + .update(workspaces) + .set({ isUnread: input.isUnread }) + .where(eq(workspaces.id, input.id)) + .run(); + + return { success: true, isUnread: input.isUnread }; + }), + close: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts index 7dc43e36b0b..dfbff3ef05a 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -1,23 +1,48 @@ +import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; /** * Mutation hook for setting the active workspace * Automatically invalidates getActive and getAll queries on success + * Shows undo toast if workspace was marked as unread (auto-cleared on switch) */ export function useSetActiveWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + }); return trpc.workspaces.setActive.useMutation({ ...options, - onSuccess: async (...args) => { + onSuccess: async (data, variables, ...rest) => { // Auto-invalidate active workspace and all workspaces queries - await utils.workspaces.getActive.invalidate(); - await utils.workspaces.getAll.invalidate(); + await Promise.all([ + utils.workspaces.getActive.invalidate(), + utils.workspaces.getAll.invalidate(), + utils.workspaces.getAllGrouped.invalidate(), + ]); + + // Show undo toast if workspace was marked as unread + if (data.wasUnread) { + toast("Marked as read", { + description: "Workspace unread marker cleared", + action: { + label: "Undo", + onClick: () => { + setUnread.mutate({ id: variables.id, isUnread: true }); + }, + }, + duration: 5000, + }); + } // Call user's onSuccess if provided - await options?.onSuccess?.(...args); + // biome-ignore lint/suspicious/noExplicitAny: spread args for compatibility + await (options?.onSuccess as any)?.(data, variables, ...rest); }, }); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts index 2c88bf1915c..cdd2075e12b 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -1,104 +1,29 @@ -import { toast } from "@superset/ui/sonner"; import { useState } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { useDeleteWorkspace } from "./useDeleteWorkspace"; - -interface UseWorkspaceDeleteHandlerParams { - id: string; - name: string; - type: "worktree" | "branch"; -} interface UseWorkspaceDeleteHandlerResult { /** Whether the delete dialog should be shown */ showDeleteDialog: boolean; /** Set whether the delete dialog should be shown */ setShowDeleteDialog: (show: boolean) => void; - /** Handle delete click - checks conditions and either deletes directly or shows dialog */ - handleDeleteClick: (e?: React.MouseEvent) => Promise; - /** Whether a delete operation is pending */ - isPending: boolean; + /** Handle delete click - always shows the dialog to let user choose close or delete */ + handleDeleteClick: (e?: React.MouseEvent) => void; } /** - * Shared hook for workspace delete logic. - * Handles the decision of whether to show confirmation dialog or delete directly. - * - * For branch workspaces: Shows dialog only if there are active terminals - * For worktree workspaces: Shows dialog if there are changes, unpushed commits, or terminals + * Shared hook for workspace delete/close dialog state. + * Always shows the confirmation dialog to let user choose between closing or deleting. */ -export function useWorkspaceDeleteHandler({ - id, - name, - type, -}: UseWorkspaceDeleteHandlerParams): UseWorkspaceDeleteHandlerResult { +export function useWorkspaceDeleteHandler(): UseWorkspaceDeleteHandlerResult { const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const deleteWorkspace = useDeleteWorkspace(); - const isBranchWorkspace = type === "branch"; - const canDeleteQuery = trpc.workspaces.canDelete.useQuery( - { id }, - { enabled: false }, - ); - - const handleDeleteClick = async (e?: React.MouseEvent) => { + const handleDeleteClick = (e?: React.MouseEvent) => { e?.stopPropagation(); - - if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; - - try { - const { data: canDeleteData } = await canDeleteQuery.refetch(); - - if (isBranchWorkspace) { - // Show dialog if we couldn't get data (safe default) or there are active terminals - if ( - !canDeleteData || - (canDeleteData.activeTerminalCount && - canDeleteData.activeTerminalCount > 0) - ) { - setShowDeleteDialog(true); - } else { - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Closing "${name}"...`, - success: `Workspace "${name}" closed`, - error: (error) => - error instanceof Error - ? `Failed to close workspace: ${error.message}` - : "Failed to close workspace", - }); - } - return; - } - - // Only skip dialog if we have data confirming it's safe to delete - const isEmpty = - canDeleteData?.canDelete && - canDeleteData.activeTerminalCount === 0 && - !canDeleteData.warning && - !canDeleteData.hasChanges && - !canDeleteData.hasUnpushedCommits; - - if (isEmpty) { - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Deleting "${name}"...`, - success: `Workspace "${name}" deleted`, - error: (error) => - error instanceof Error - ? `Failed to delete workspace: ${error.message}` - : "Failed to delete workspace", - }); - } else { - setShowDeleteDialog(true); - } - } catch { - setShowDeleteDialog(true); - } + setShowDeleteDialog(true); }; return { showDeleteDialog, setShowDeleteDialog, handleDeleteClick, - isPending: deleteWorkspace.isPending || canDeleteQuery.isFetching, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx index 0b042f2ef52..75505bf3313 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx @@ -11,6 +11,7 @@ interface Workspace { branch: string; name: string; tabOrder: number; + isUnread: boolean; } interface WorkspaceGroupProps { @@ -79,6 +80,7 @@ export function WorkspaceGroup({ branch={workspace.branch} title={workspace.name} isActive={workspace.id === activeWorkspaceId} + isUnread={workspace.isUnread} index={index} width={workspaceWidth} onMouseEnter={() => onWorkspaceHover(workspace.id)} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 2f91dbcbb9c..b98e36c98c2 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -29,6 +29,7 @@ interface WorkspaceItemProps { branch?: string; title: string; isActive: boolean; + isUnread?: boolean; index: number; width: number; onMouseEnter?: () => void; @@ -43,6 +44,7 @@ export function WorkspaceItem({ branch, title, isActive, + isUnread = false, index, width, onMouseEnter, @@ -61,17 +63,20 @@ export function WorkspaceItem({ // Shared delete logic const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = - useWorkspaceDeleteHandler({ id, name: title, type: workspaceType }); + useWorkspaceDeleteHandler(); - // Check if any pane in tabs belonging to this workspace needs attention + // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) const workspaceTabs = tabs.filter((t) => t.workspaceId === id); const workspacePaneIds = new Set( workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); - const needsAttention = Object.values(panes) + const hasPaneAttention = Object.values(panes) .filter((p) => workspacePaneIds.has(p.id)) .some((p) => p.needsAttention); + // Show indicator if workspace is manually marked as unread OR has pane-level attention + const needsAttention = isUnread || hasPaneAttention; + const [{ isDragging }, drag] = useDrag( () => ({ type: WORKSPACE_TYPE, @@ -104,6 +109,7 @@ export function WorkspaceItem({ workspaceId={id} worktreePath={worktreePath} workspaceAlias={title} + isUnread={isUnread} onRename={rename.startRename} canRename={!isBranchWorkspace} showHoverCard={!isBranchWorkspace} @@ -228,13 +234,13 @@ export function WorkspaceItem({ "mt-1 absolute right-1 top-1/2 -translate-y-1/2 cursor-pointer size-5 group-hover:opacity-100", isActive ? "opacity-90" : "opacity-0", )} - aria-label="Delete workspace" + aria-label="Close or delete workspace" > - Delete workspace + Close or delete )} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx index e514e552b8f..81b8b6ce181 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -11,9 +11,8 @@ import { HoverCardTrigger, } from "@superset/ui/hover-card"; import type { ReactNode } from "react"; -import { LuEyeOff } from "react-icons/lu"; +import { LuEye, LuEyeOff } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { useTabsStore } from "renderer/stores/tabs/store"; import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; interface WorkspaceItemContextMenuProps { @@ -21,6 +20,7 @@ interface WorkspaceItemContextMenuProps { workspaceId: string; worktreePath: string; workspaceAlias?: string; + isUnread?: boolean; onRename: () => void; canRename?: boolean; showHoverCard?: boolean; @@ -31,12 +31,18 @@ export function WorkspaceItemContextMenu({ workspaceId, worktreePath, workspaceAlias, + isUnread = false, onRename, canRename = true, showHoverCard = true, }: WorkspaceItemContextMenuProps) { + const utils = trpc.useUtils(); const openInFinder = trpc.external.openInFinder.useMutation(); - const markWorkspaceAsUnread = useTabsStore((s) => s.markWorkspaceAsUnread); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + }); const handleOpenInFinder = () => { if (worktreePath) { @@ -44,10 +50,26 @@ export function WorkspaceItemContextMenu({ } }; - const handleMarkAsUnread = () => { - markWorkspaceAsUnread(workspaceId); + const handleToggleUnread = () => { + setUnread.mutate({ id: workspaceId, isUnread: !isUnread }); }; + const unreadMenuItem = ( + + {isUnread ? ( + <> + + Mark as Read + + ) : ( + <> + + Mark as Unread + + )} + + ); + // For branch workspaces, just show context menu without hover card if (!showHoverCard) { return ( @@ -64,10 +86,7 @@ export function WorkspaceItemContextMenu({ Open in Finder - - - Mark as Unread - + {unreadMenuItem} ); @@ -90,10 +109,7 @@ export function WorkspaceItemContextMenu({ Open in Finder - - - Mark as Unread - + {unreadMenuItem} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 0f52fec1a79..75c933b2482 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -22,6 +22,7 @@ interface Workspace { branch: string; name: string; tabOrder: number; + isUnread: boolean; } interface ProjectSectionProps { @@ -95,6 +96,7 @@ export function ProjectSection({ branch={workspace.branch} type={workspace.type} isActive={workspace.id === activeWorkspaceId} + isUnread={workspace.isUnread} index={index} shortcutIndex={shortcutBaseIndex + index} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index a955e51a65d..9a1ca16b45d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -17,7 +17,7 @@ import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; -import { LuEyeOff, LuGitBranch } from "react-icons/lu"; +import { LuEye, LuEyeOff, LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useReorderWorkspaces, @@ -49,6 +49,7 @@ interface WorkspaceListItemProps { branch: string; type: "worktree" | "branch"; isActive: boolean; + isUnread?: boolean; index: number; shortcutIndex?: number; } @@ -61,6 +62,7 @@ export function WorkspaceListItem({ branch, type, isActive, + isUnread = false, index, shortcutIndex, }: WorkspaceListItemProps) { @@ -71,15 +73,20 @@ export function WorkspaceListItem({ const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); - const markWorkspaceAsUnread = useTabsStore((s) => s.markWorkspaceAsUnread); const clearWorkspaceAttention = useTabsStore( (s) => s.clearWorkspaceAttention, ); + const utils = trpc.useUtils(); const openInFinder = trpc.external.openInFinder.useMutation(); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + }); // Shared delete logic const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = - useWorkspaceDeleteHandler({ id, name, type }); + useWorkspaceDeleteHandler(); // Lazy-load GitHub status on hover to avoid N+1 queries const { data: githubStatus } = trpc.workspaces.getGitHubStatus.useQuery( @@ -90,15 +97,18 @@ export function WorkspaceListItem({ }, ); - // Check if any pane in tabs belonging to this workspace needs attention + // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) const workspaceTabs = tabs.filter((t) => t.workspaceId === id); const workspacePaneIds = new Set( workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); - const needsAttention = Object.values(panes) + const hasPaneAttention = Object.values(panes) .filter((p) => workspacePaneIds.has(p.id)) .some((p) => p.needsAttention); + // Show indicator if workspace is manually marked as unread OR has pane-level attention + const needsAttention = isUnread || hasPaneAttention; + const handleClick = () => { if (!rename.isRenaming) { setActiveWorkspace.mutate({ id }); @@ -118,8 +128,8 @@ export function WorkspaceListItem({ } }; - const handleMarkAsUnread = () => { - markWorkspaceAsUnread(id); + const handleToggleUnread = () => { + setUnread.mutate({ id, isUnread: !isUnread }); }; // Drag and drop @@ -251,19 +261,35 @@ export function WorkspaceListItem({ "size-5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity", isActive && "opacity-70", )} - aria-label="Delete workspace" + aria-label="Close or delete workspace" > - Delete workspace + Close or delete )} ); + const unreadMenuItem = ( + + {isUnread ? ( + <> + + Mark as Read + + ) : ( + <> + + Mark as Unread + + )} + + ); + // Wrap with context menu and hover card if (isBranchWorkspace) { return ( @@ -275,10 +301,7 @@ export function WorkspaceListItem({ Open in Finder - - - Mark as Unread - + {unreadMenuItem} - - - Mark as Unread - + {unreadMenuItem} diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 9347c4d1b21..6e6823d33e0 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -543,33 +543,6 @@ export const useTabsStore = create()( })); }, - markWorkspaceAsUnread: (workspaceId) => { - const state = get(); - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId, - ); - const workspacePaneIds = workspaceTabs.flatMap((t) => - extractPaneIdsFromLayout(t.layout), - ); - - if (workspacePaneIds.length === 0) { - console.log( - "[tabs/markUnread] No panes to mark for workspace:", - workspaceId, - ); - return; - } - - const newPanes = { ...state.panes }; - for (const paneId of workspacePaneIds) { - if (newPanes[paneId]) { - newPanes[paneId] = { ...newPanes[paneId], needsAttention: true }; - } - } - - set({ panes: newPanes }); - }, - clearWorkspaceAttention: (workspaceId) => { const state = get(); const workspaceTabs = state.tabs.filter( diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index d6f43ab6e00..64a298ab958 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -70,7 +70,6 @@ export interface TabsStore extends TabsState { setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; setNeedsAttention: (paneId: string, needsAttention: boolean) => void; - markWorkspaceAsUnread: (workspaceId: string) => void; clearWorkspaceAttention: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, diff --git a/packages/local-db/drizzle/0007_add_workspace_is_unread.sql b/packages/local-db/drizzle/0007_add_workspace_is_unread.sql new file mode 100644 index 00000000000..9f3ca8ec300 --- /dev/null +++ b/packages/local-db/drizzle/0007_add_workspace_is_unread.sql @@ -0,0 +1 @@ +ALTER TABLE `workspaces` ADD `is_unread` integer DEFAULT false; diff --git a/packages/local-db/drizzle/meta/0007_snapshot.json b/packages/local-db/drizzle/meta/0007_snapshot.json new file mode 100644 index 00000000000..dbf24a697c3 --- /dev/null +++ b/packages/local-db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,992 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a7b8c9d0-e1f2-3456-7890-abcdef123456", + "prevId": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "navigation_style": { + "name": "navigation_style", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index d72a8fe1e80..c63757dc471 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1767230000000, "tag": "0006_add_unique_branch_workspace_index", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1767350000000, + "tag": "0007_add_workspace_is_unread", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index c708a32964f..b5437dd783d 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -101,6 +101,7 @@ export const workspaces = sqliteTable( lastOpenedAt: integer("last_opened_at") .notNull() .$defaultFn(() => Date.now()), + isUnread: integer("is_unread", { mode: "boolean" }).default(false), }, (table) => [ index("workspaces_project_id_idx").on(table.projectId), From a010e4f8894ba0b3757765c576a90ab952fab531 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 10:22:17 +0200 Subject: [PATCH 29/64] fix(desktop): resolve TypeScript errors in workspace-sidebar branch - Fix hotkey() typo to defineHotkey() in hotkeys.ts - Add getHotkey() helper that returns string (not null) for useHotkeys compatibility - Replace all HOTKEYS.*.keys usages with getHotkey() calls - Fix 'against-main' to 'against-base' in ChangeCategory references - Add proper type annotation in WorkspaceSidebarControl.tsx --- .../desktop/src/lib/trpc/routers/ui-state/index.ts | 2 +- .../src/renderer/hooks/useWorkspaceShortcuts.ts | 6 +++--- .../components/TopBar/WorkspaceSidebarControl.tsx | 11 +++++++++-- .../TabView/FileViewerPane/FileViewerPane.tsx | 8 ++++---- .../main/components/WorkspaceView/index.tsx | 14 +++++++------- apps/desktop/src/renderer/screens/main/index.tsx | 4 ++-- apps/desktop/src/shared/hotkeys.ts | 11 ++++++++++- 7 files changed, 36 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 77b32e36286..acbfa3bae18 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -19,7 +19,7 @@ const fileViewerStateSchema = z.object({ isLocked: z.boolean(), diffLayout: z.enum(["inline", "side-by-side"]), diffCategory: z - .enum(["against-main", "committed", "staged", "unstaged"]) + .enum(["against-base", "committed", "staged", "unstaged"]) .optional(), commitHash: z.string().optional(), oldPath: z.string().optional(), diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index cd222045883..a37a8684377 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -5,7 +5,7 @@ import { useCreateBranchWorkspace, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; -import { HOTKEYS } from "shared/hotkeys"; +import { getHotkey } from "shared/hotkeys"; /** * Shared hook for workspace keyboard shortcuts and auto-creation logic. @@ -105,8 +105,8 @@ export function useWorkspaceShortcuts() { }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); useHotkeys(workspaceKeys, handleWorkspaceSwitch); - useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); - useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); + useHotkeys(getHotkey("PREV_WORKSPACE"), handlePrevWorkspace); + useHotkeys(getHotkey("NEXT_WORKSPACE"), handleNextWorkspace); return { groups, diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx index 9f73f0e465b..a5a517901f6 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -3,7 +3,11 @@ import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { LuPanelLeft, LuPanelLeftClose } from "react-icons/lu"; import { useWorkspaceSidebarStore } from "renderer/stores"; -import { HOTKEYS } from "shared/hotkeys"; +import { + formatHotkeyDisplay, + getCurrentPlatform, + getHotkey, +} from "shared/hotkeys"; export function WorkspaceSidebarControl() { const { isOpen, toggleOpen } = useWorkspaceSidebarStore(); @@ -29,7 +33,10 @@ export function WorkspaceSidebarControl() { Toggle Workspaces - {HOTKEYS.TOGGLE_WORKSPACE_SIDEBAR.display.map((key) => ( + {formatHotkeyDisplay( + getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), + getCurrentPlatform(), + ).map((key) => ( {key} ))} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 4a842517364..e8a975f2ee4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -137,10 +137,10 @@ export function FileViewerPane({ const commitHash = fileViewer?.commitHash; const oldPath = fileViewer?.oldPath; - // Fetch branch info for against-main diffs (P1-1) + // Fetch branch info for against-base diffs (P1-1) const { data: branchData } = trpc.changes.getBranches.useQuery( { worktreePath }, - { enabled: !!worktreePath && diffCategory === "against-main" }, + { enabled: !!worktreePath && diffCategory === "against-base" }, ); const effectiveBaseBranch = branchData?.defaultBranch ?? "main"; @@ -274,9 +274,9 @@ export function FileViewerPane({ oldPath, category: diffCategory ?? "unstaged", commitHash, - // P1-1: Pass defaultBranch for against-main diffs + // P1-1: Pass defaultBranch for against-base diffs defaultBranch: - diffCategory === "against-main" ? effectiveBaseBranch : undefined, + diffCategory === "against-base" ? effectiveBaseBranch : undefined, }, { enabled: diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index b8f8f07bbf2..38c3045058f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -4,7 +4,7 @@ import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; -import { HOTKEYS } from "shared/hotkeys"; +import { getHotkey } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; @@ -52,7 +52,7 @@ export function WorkspaceView() { : "workbench"; // Tab management shortcuts - useHotkeys(HOTKEYS.NEW_TERMINAL.keys, () => { + useHotkeys(getHotkey("NEW_TERMINAL"), () => { if (activeWorkspaceId) { // If in Review mode, switch to Workbench first if (viewMode === "review") { @@ -62,7 +62,7 @@ export function WorkspaceView() { } }, [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode]); - useHotkeys(HOTKEYS.CLOSE_TERMINAL.keys, () => { + useHotkeys(getHotkey("CLOSE_TERMINAL"), () => { // Close focused pane (which may close the tab if it's the last pane) if (focusedPaneId) { removePane(focusedPaneId); @@ -70,7 +70,7 @@ export function WorkspaceView() { }, [focusedPaneId, removePane]); // Switch between tabs (⌘+Up/Down) - useHotkeys(HOTKEYS.PREV_TERMINAL.keys, () => { + useHotkeys(getHotkey("PREV_TERMINAL"), () => { if (!activeWorkspaceId || !activeTabId) return; const index = tabs.findIndex((t) => t.id === activeTabId); if (index > 0) { @@ -78,7 +78,7 @@ export function WorkspaceView() { } }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); - useHotkeys(HOTKEYS.NEXT_TERMINAL.keys, () => { + useHotkeys(getHotkey("NEXT_TERMINAL"), () => { if (!activeWorkspaceId || !activeTabId) return; const index = tabs.findIndex((t) => t.id === activeTabId); if (index < tabs.length - 1) { @@ -87,7 +87,7 @@ export function WorkspaceView() { }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); // Switch between panes within a tab (⌘+⌥+Left/Right) - useHotkeys(HOTKEYS.PREV_PANE.keys, () => { + useHotkeys(getHotkey("PREV_PANE"), () => { if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); if (prevPaneId) { @@ -95,7 +95,7 @@ export function WorkspaceView() { } }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); - useHotkeys(HOTKEYS.NEXT_PANE.keys, () => { + useHotkeys(getHotkey("NEXT_PANE"), () => { if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); if (nextPaneId) { diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 620b56b7463..89a05a15854 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -22,7 +22,7 @@ import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener" import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceSidebarStore } from "renderer/stores/workspace-sidebar-state"; import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; -import { HOTKEYS } from "shared/hotkeys"; +import { getHotkey, HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -122,7 +122,7 @@ export function MainScreen() { [toggleSidebar, isWorkspaceView], ); - useHotkeys(HOTKEYS.TOGGLE_WORKSPACE_SIDEBAR.keys, () => { + useHotkeys(getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), () => { if (isSidebarMode) toggleWorkspaceSidebar(); }, [toggleWorkspaceSidebar, isSidebarMode]); diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index b650d06d791..1a1e78bd24b 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -426,7 +426,7 @@ export const HOTKEYS = { label: "Toggle Files Sidebar", category: "Layout", }), - TOGGLE_WORKSPACE_SIDEBAR: hotkey({ + TOGGLE_WORKSPACE_SIDEBAR: defineHotkey({ keys: "meta+shift+b", label: "Toggle Workspaces Sidebar", category: "Layout", @@ -580,6 +580,15 @@ export function getDefaultHotkey( return HOTKEYS[id].defaults[platform]; } +/** + * Get the hotkey binding for the current platform. + * Convenience wrapper around getDefaultHotkey. + * Returns empty string if no hotkey is defined (safe for useHotkeys). + */ +export function getHotkey(id: HotkeyId): string { + return getDefaultHotkey(id, getCurrentPlatform()) ?? ""; +} + export function getEffectiveHotkey( id: HotkeyId, overrides: Partial>, From a44d74d89ff40ebf480e4a861d513a5f8574bded Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 11:36:37 +0200 Subject: [PATCH 30/64] style(desktop): use border instead of bg for active tab in GroupStrip Replace intense bg-tertiary-active background with subtle border-b-2 border-border for active tab state - cleaner visual that's less heavy --- .../ContentView/TabsContent/GroupStrip/GroupStrip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index e6f271b53a4..f68bce5c1ac 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -35,7 +35,7 @@ function GroupItem({ className={cn( "flex items-center gap-1.5 rounded-t-md transition-all w-full shrink-0 pl-3 pr-6 h-[80%]", isActive - ? "text-foreground bg-tertiary-active" + ? "text-foreground border-b-2 border-border" : "text-muted-foreground hover:text-foreground hover:bg-tertiary/30", )} > @@ -132,7 +132,7 @@ export function GroupStrip() { }; return ( -
+
{tabs.length > 0 && (
{tabs.map((tab) => ( From e6eb772f39e16fd38eae3276c2e73c9a80726ca7 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 11:37:08 +0200 Subject: [PATCH 31/64] style(desktop): use top/side borders for active tab in GroupStrip Change from bottom border to top/left/right borders for a traditional tab appearance that connects to content below --- .../ContentView/TabsContent/GroupStrip/GroupStrip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index f68bce5c1ac..77790ae7b88 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -35,7 +35,7 @@ function GroupItem({ className={cn( "flex items-center gap-1.5 rounded-t-md transition-all w-full shrink-0 pl-3 pr-6 h-[80%]", isActive - ? "text-foreground border-b-2 border-border" + ? "text-foreground border-t border-l border-r border-border" : "text-muted-foreground hover:text-foreground hover:bg-tertiary/30", )} > From 4f15753807e6e906b04f71b329922f0b7900d0e5 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 11:56:08 +0200 Subject: [PATCH 32/64] fix(desktop): update sidebar toggle tooltip to 'Toggle Changes Sidebar' --- .../screens/main/components/SidebarControl/SidebarControl.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index d2337d7ba5d..fb17d261ce7 100644 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -14,7 +14,7 @@ export function SidebarControl() { variant="ghost" size="icon" onClick={toggleSidebar} - aria-label={isSidebarOpen ? "Hide sidebar" : "Show sidebar"} + aria-label={isSidebarOpen ? "Hide Changes Sidebar" : "Show Changes Sidebar"} className="no-drag" > {isSidebarOpen ? ( @@ -26,7 +26,7 @@ export function SidebarControl() { From 74a1eabe2a1354c01a9994d1d21eb67f714a2c84 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 11:56:57 +0200 Subject: [PATCH 33/64] feat(desktop): add sidebar toggle to TabsContent in sidebar navigation mode Show SidebarControl in GroupStrip header and EmptyTabView when using sidebar navigation style, allowing users to toggle the changes sidebar --- .../ContentView/TabsContent/EmptyTabView.tsx | 47 ++++++++++++------- .../ContentView/TabsContent/index.tsx | 16 ++++++- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index 0f9a435a873..53d29745fe4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -1,35 +1,50 @@ import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { HiMiniCommandLine } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; +import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; +import { SidebarControl } from "../../../SidebarControl"; export function EmptyTabView() { const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); + // Get navigation style to conditionally show sidebar toggle + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const isSidebarMode = + (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; + const shortcuts = [ { label: "New Terminal", display: newTerminalDisplay }, { label: "Open in App", display: openInAppDisplay }, ]; return ( -
-
- -
+
+ {isSidebarMode && ( +
+ +
+ )} +
+
+ +
-

No terminal open

+

No terminal open

-
- {shortcuts.map((shortcut) => ( -
- - {shortcut.display.map((key) => ( - {key} - ))} - - {shortcut.label} -
- ))} +
+ {shortcuts.map((shortcut) => ( +
+ + {shortcut.display.map((key) => ( + {key} + ))} + + {shortcut.label} +
+ ))} +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 8dc969d541a..36d2a427b9a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; +import { SidebarControl } from "../../../SidebarControl"; import { EmptyTabView } from "./EmptyTabView"; import { GroupStrip } from "./GroupStrip"; import { TabView } from "./TabView"; @@ -12,6 +14,11 @@ export function TabsContent() { const panes = useTabsStore((s) => s.panes); const activeTabIds = useTabsStore((s) => s.activeTabIds); + // Get navigation style to conditionally show sidebar toggle + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const isSidebarMode = + (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; + const tabToRender = useMemo(() => { if (!activeWorkspaceId) return null; const activeTabId = activeTabIds[activeWorkspaceId]; @@ -26,7 +33,14 @@ export function TabsContent() { return (
- +
+ {isSidebarMode && ( +
+ +
+ )} + +
From 461a24df1667120ee72d88086ca9396f5aad551a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 14:18:10 +0200 Subject: [PATCH 34/64] fix(desktop): lift SidebarControl to ContentView to fix review mode regression The sidebar toggle was missing when switching to Review mode because it was rendered inside TabsContent, which doesn't render in review mode. Changes: - Create ContentHeader component at ContentView level with leadingAction slot - ContentView now owns SidebarControl placement for both workbench and review modes - Remove SidebarControl from TabsContent and EmptyTabView (now handled by parent) - Remove unused BranchIndicator component and HOTKEYS import (cleanup) --- .../WorkspaceControls/BranchIndicator.tsx | 29 ------------ .../WorkspaceControls/WorkspaceControls.tsx | 4 -- .../screens/main/components/TopBar/index.tsx | 1 - .../ContentHeader/ContentHeader.tsx | 19 ++++++++ .../ContentView/ContentHeader/index.ts | 1 + .../ContentView/TabsContent/EmptyTabView.tsx | 47 +++++++------------ .../ContentView/TabsContent/index.tsx | 22 +-------- .../WorkspaceView/ContentView/index.tsx | 34 ++++++++++++-- .../src/renderer/screens/main/index.tsx | 2 +- 9 files changed, 69 insertions(+), 90 deletions(-) delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx deleted file mode 100644 index 3146293114f..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/BranchIndicator.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { GoGitBranch } from "react-icons/go"; - -interface BranchIndicatorProps { - branch: string | undefined; -} - -export function BranchIndicator({ branch }: BranchIndicatorProps) { - if (!branch) return null; - - return ( - - - - - - Current branch - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx index 2c09ecd95c7..e590051951c 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx @@ -1,24 +1,20 @@ -import { BranchIndicator } from "./BranchIndicator"; import { OpenInMenuButton } from "./OpenInMenuButton"; import { ViewModeToggleCompact } from "./ViewModeToggleCompact"; interface WorkspaceControlsProps { workspaceId: string | undefined; worktreePath: string | undefined; - branch: string | undefined; } export function WorkspaceControls({ workspaceId, worktreePath, - branch, }: WorkspaceControlsProps) { // Don't render if no active workspace with a worktree path if (!workspaceId || !worktreePath) return null; return (
-
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index f537b4793ff..a3ea4f4167a 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -50,7 +50,6 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { {!isMac && } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx new file mode 100644 index 00000000000..6839ca39b04 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +interface ContentHeaderProps { + /** Optional leading action (e.g., SidebarControl) */ + leadingAction?: ReactNode; + /** Mode-specific header content (e.g., GroupStrip or file info) */ + children: ReactNode; +} + +export function ContentHeader({ leadingAction, children }: ContentHeaderProps) { + return ( +
+ {leadingAction && ( +
{leadingAction}
+ )} + {children} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts new file mode 100644 index 00000000000..26fb6ccbc3e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts @@ -0,0 +1 @@ +export { ContentHeader } from "./ContentHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index 53d29745fe4..7be04c0c208 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -1,50 +1,35 @@ import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { HiMiniCommandLine } from "react-icons/hi2"; -import { trpc } from "renderer/lib/trpc"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; -import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; -import { SidebarControl } from "../../../SidebarControl"; export function EmptyTabView() { const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); - // Get navigation style to conditionally show sidebar toggle - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - const isSidebarMode = - (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; - const shortcuts = [ { label: "New Terminal", display: newTerminalDisplay }, { label: "Open in App", display: openInAppDisplay }, ]; return ( -
- {isSidebarMode && ( -
- -
- )} -
-
- -
+
+
+ +
-

No terminal open

+

No terminal open

-
- {shortcuts.map((shortcut) => ( -
- - {shortcut.display.map((key) => ( - {key} - ))} - - {shortcut.label} -
- ))} -
+
+ {shortcuts.map((shortcut) => ( +
+ + {shortcut.display.map((key) => ( + {key} + ))} + + {shortcut.label} +
+ ))}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 36d2a427b9a..fb3908f55f4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,10 +1,7 @@ import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; -import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; -import { SidebarControl } from "../../../SidebarControl"; import { EmptyTabView } from "./EmptyTabView"; -import { GroupStrip } from "./GroupStrip"; import { TabView } from "./TabView"; export function TabsContent() { @@ -14,11 +11,6 @@ export function TabsContent() { const panes = useTabsStore((s) => s.panes); const activeTabIds = useTabsStore((s) => s.activeTabIds); - // Get navigation style to conditionally show sidebar toggle - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - const isSidebarMode = - (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; - const tabToRender = useMemo(() => { if (!activeWorkspaceId) return null; const activeTabId = activeTabIds[activeWorkspaceId]; @@ -32,18 +24,8 @@ export function TabsContent() { } return ( -
-
- {isSidebarMode && ( -
- -
- )} - -
-
- -
+
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 835e914fc05..d3f4675c0e0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,7 +1,11 @@ import { trpc } from "renderer/lib/trpc"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; +import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; +import { SidebarControl } from "../../SidebarControl"; import { ChangesContent } from "./ChangesContent"; +import { ContentHeader } from "./ContentHeader"; import { TabsContent } from "./TabsContent"; +import { GroupStrip } from "./TabsContent/GroupStrip"; export function ContentView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); @@ -16,15 +20,37 @@ export function ContentView() { ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") : "workbench"; + // Get navigation style to conditionally show sidebar toggle + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const isSidebarMode = + (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; + if (viewMode === "review") { return ( -
-
- +
+ {isSidebarMode && ( + }> + {/* Review mode has no additional header content - FileHeader is inside ChangesContent */} +
+ + )} +
+
+ +
); } - return ; + return ( +
+ : undefined} + > + + + +
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 89a05a15854..580f08bbf7f 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -22,7 +22,7 @@ import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener" import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceSidebarStore } from "renderer/stores/workspace-sidebar-state"; import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; -import { getHotkey, HOTKEYS } from "shared/hotkeys"; +import { getHotkey } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; From 3681096e106d4f20cdb01f32ee277d5781c68ce6 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Fri, 2 Jan 2026 14:35:05 +0200 Subject: [PATCH 35/64] refactor(desktop): rename NEW_TERMINAL hotkey to NEW_GROUP for clarity CMD+T creates a new Group (tab container), not a terminal pane. This aligns the hotkey naming with the existing 'New Group' button tooltip. --- .../WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx | 4 ++-- .../renderer/screens/main/components/WorkspaceView/index.tsx | 2 +- apps/desktop/src/shared/hotkeys.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index 7be04c0c208..daaff3366fd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -3,11 +3,11 @@ import { HiMiniCommandLine } from "react-icons/hi2"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; export function EmptyTabView() { - const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); + const newGroupDisplay = useHotkeyDisplay("NEW_GROUP"); const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); const shortcuts = [ - { label: "New Terminal", display: newTerminalDisplay }, + { label: "New Group", display: newGroupDisplay }, { label: "Open in App", display: openInAppDisplay }, ]; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 38c3045058f..84f22532079 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -52,7 +52,7 @@ export function WorkspaceView() { : "workbench"; // Tab management shortcuts - useHotkeys(getHotkey("NEW_TERMINAL"), () => { + useHotkeys(getHotkey("NEW_GROUP"), () => { if (activeWorkspaceId) { // If in Review mode, switch to Workbench first if (viewMode === "review") { diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index 1a1e78bd24b..2a2b4620789 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -457,9 +457,9 @@ export const HOTKEYS = { category: "Terminal", description: "Search text in the active terminal", }), - NEW_TERMINAL: defineHotkey({ + NEW_GROUP: defineHotkey({ keys: "meta+t", - label: "New Terminal", + label: "New Group", category: "Terminal", }), CLOSE_TERMINAL: defineHotkey({ From 8795933f902de830c7714efc78e98edd4b58976b Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 09:36:52 +0200 Subject: [PATCH 36/64] feat(desktop): move workspace controls to content header in sidebar mode In sidebar navigation mode, move the 'Open In' button and 'Workbench/Review' toggle from TopBar down to ContentHeader alongside the group tabs. This keeps the profile dropdown in TopBar while placing workspace controls closer to the content they affect. Changes: - ContentHeader: Add trailingAction prop for right-side controls - ContentView: Pass WorkspaceControls as trailingAction when isSidebarMode - TopBar: Only render WorkspaceControls when not in sidebar mode The controls are visible in both Workbench (with group tabs) and Review (without group tabs) modes. Top-bar navigation mode is unchanged. --- .../screens/main/components/TopBar/index.tsx | 10 ++++++---- .../ContentView/ContentHeader/ContentHeader.tsx | 13 +++++++++++-- .../WorkspaceView/ContentView/index.tsx | 17 +++++++++++++++-- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index a3ea4f4167a..f9f163791dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -47,10 +47,12 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { )}
- + {!isSidebarMode && ( + + )} {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx index 6839ca39b04..85abdc17a8d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -5,15 +5,24 @@ interface ContentHeaderProps { leadingAction?: ReactNode; /** Mode-specific header content (e.g., GroupStrip or file info) */ children: ReactNode; + /** Optional trailing action (e.g., WorkspaceControls) */ + trailingAction?: ReactNode; } -export function ContentHeader({ leadingAction, children }: ContentHeaderProps) { +export function ContentHeader({ + leadingAction, + children, + trailingAction, +}: ContentHeaderProps) { return (
{leadingAction && (
{leadingAction}
)} - {children} +
{children}
+ {trailingAction && ( +
{trailingAction}
+ )}
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index d3f4675c0e0..01d80ffa175 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -2,6 +2,7 @@ import { trpc } from "renderer/lib/trpc"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; import { SidebarControl } from "../../SidebarControl"; +import { WorkspaceControls } from "../../TopBar/WorkspaceControls"; import { ChangesContent } from "./ChangesContent"; import { ContentHeader } from "./ContentHeader"; import { TabsContent } from "./TabsContent"; @@ -25,12 +26,23 @@ export function ContentView() { const isSidebarMode = (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; + // Render WorkspaceControls in ContentHeader when in sidebar mode + const workspaceControls = isSidebarMode ? ( + + ) : undefined; + if (viewMode === "review") { return (
{isSidebarMode && ( - }> - {/* Review mode has no additional header content - FileHeader is inside ChangesContent */} + } + trailingAction={workspaceControls} + > + {/* Review mode has no group tabs */}
)} @@ -47,6 +59,7 @@ export function ContentView() {
: undefined} + trailingAction={workspaceControls} > From 6a9850e872b8321d5644f9400ab7e9212d95fc72 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 16:41:08 +0200 Subject: [PATCH 37/64] fix(desktop): harden file viewer security and improve terminal link handling Security fixes (code review feedback): - P0-1: Fix ENOENT handling in secure-fs to detect dangling symlinks and validate parent directory chains for write operations - P0-2: Add centralized symlink-escape enforcement to secureFs.readFile and readFileBuffer - reads now blocked if symlink escapes worktree - Add 'symlink-escape' error reason to distinguish from other validation errors - Use path.relative() for safer boundary checks instead of string prefix matching - P1: Handle undefined workspaceCwd gracefully in Terminal file-viewer mode by falling back to external editor when workspace is still initializing - P2: Update FileViewerState schema comment to clarify intentional omission of transient fields (initialLine/initialColumn) - P2: Document unique branch-workspace index limitation in schema.ts Feature completion: - Terminal file links now pass line/column to File Viewer for initial scroll - FileViewerPane applies initial line/column navigation in raw mode - Add initialLine/initialColumn to FileViewerState and store types --- .../lib/trpc/routers/changes/file-contents.ts | 11 +- .../routers/changes/security/secure-fs.ts | 178 +++++++++++++++++- .../src/lib/trpc/routers/ui-state/index.ts | 4 +- .../TabView/FileViewerPane/FileViewerPane.tsx | 42 ++++- .../TabsContent/Terminal/Terminal.tsx | 61 +++--- .../desktop/src/renderer/stores/tabs/store.ts | 2 + .../desktop/src/renderer/stores/tabs/types.ts | 4 + .../desktop/src/renderer/stores/tabs/utils.ts | 6 + apps/desktop/src/shared/tabs-types.ts | 4 + packages/local-db/src/schema/schema.ts | 6 + 10 files changed, 284 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index dc4aba8f4e5..4b2ae5aca2c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -22,7 +22,12 @@ type ReadWorkingFileResult = | { ok: true; content: string; truncated: boolean; byteLength: number } | { ok: false; - reason: "not-found" | "too-large" | "binary" | "outside-worktree"; + reason: + | "not-found" + | "too-large" + | "binary" + | "outside-worktree" + | "symlink-escape"; }; /** @@ -131,6 +136,10 @@ export const createFileContentsRouter = () => { }; } catch (error) { if (error instanceof PathValidationError) { + // Map specific error codes to distinct reasons + if (error.code === "SYMLINK_ESCAPE") { + return { ok: false, reason: "symlink-escape" }; + } return { ok: false, reason: "outside-worktree" }; } // File not found or other read error diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 5c47f6e7c35..4f68e28932e 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -2,11 +2,13 @@ import type { Stats } from "node:fs"; import { lstat, readFile, + readlink, realpath, rm, stat, writeFile, } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import { assertRegisteredWorktree, PathValidationError, @@ -25,6 +27,137 @@ import { * See path-validation.ts for the full security model and threat assumptions. */ +/** + * Check if a resolved path is within the worktree boundary using path.relative(). + * This is safer than string prefix matching which can have boundary bugs. + */ +function isPathWithinWorktree( + worktreeReal: string, + targetReal: string, +): boolean { + if (targetReal === worktreeReal) { + return true; + } + const relativePath = relative(worktreeReal, targetReal); + // If relative path starts with ".." or is absolute, it's outside + return ( + !relativePath.startsWith("..") && + !isAbsolute(relativePath) && + relativePath !== "" + ); +} + +/** + * Validate that the parent directory chain stays within the worktree. + * Handles the case where the target file doesn't exist yet (ENOENT). + * + * This function walks up the directory tree to find the first existing + * ancestor and validates it. It also detects dangling symlinks by checking + * if any component is a symlink pointing outside the worktree. + * + * @throws PathValidationError if any ancestor escapes the worktree + */ +async function assertParentInWorktree( + worktreePath: string, + fullPath: string, +): Promise { + const worktreeReal = await realpath(worktreePath); + let currentPath = dirname(fullPath); + + // Walk up the directory tree until we find an existing directory + while (currentPath !== dirname(currentPath)) { + // Stop at filesystem root + try { + // First check if this path component is a symlink (even if target doesn't exist) + const stats = await lstat(currentPath); + + if (stats.isSymbolicLink()) { + // This is a symlink - validate its target even if it doesn't exist + const linkTarget = await readlink(currentPath); + // Resolve the link target relative to the symlink's parent + const resolvedTarget = isAbsolute(linkTarget) + ? linkTarget + : resolve(dirname(currentPath), linkTarget); + + // Try to get the realpath of the resolved target + try { + const targetReal = await realpath(resolvedTarget); + if (!isPathWithinWorktree(worktreeReal, targetReal)) { + throw new PathValidationError( + "Symlink in path resolves outside the worktree", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + // Target doesn't exist - check if the resolved target path + // would be within worktree if it existed + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // For dangling symlinks, validate the target path itself + // We need to check if the target, when resolved, would be in worktree + // This is conservative: if we can't determine, fail closed + const targetRelative = relative(worktreeReal, resolvedTarget); + if (targetRelative.startsWith("..") || isAbsolute(targetRelative)) { + throw new PathValidationError( + "Dangling symlink points outside the worktree", + "SYMLINK_ESCAPE", + ); + } + // Target would be within worktree if it existed - continue + return; + } + if (error instanceof PathValidationError) { + throw error; + } + // Other errors - fail closed for security + throw new PathValidationError( + "Cannot validate symlink target", + "SYMLINK_ESCAPE", + ); + } + return; // Symlink validated successfully + } + + // Not a symlink - get realpath and validate + const parentReal = await realpath(currentPath); + if (!isPathWithinWorktree(worktreeReal, parentReal)) { + throw new PathValidationError( + "Parent directory resolves outside the worktree", + "SYMLINK_ESCAPE", + ); + } + return; // Found valid ancestor + } catch (error) { + if (error instanceof PathValidationError) { + throw error; + } + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // This ancestor doesn't exist either, keep walking up + currentPath = dirname(currentPath); + continue; + } + // Other errors (EACCES, ENOTDIR, etc.) - fail closed for security + throw new PathValidationError( + "Cannot validate path ancestry", + "SYMLINK_ESCAPE", + ); + } + } + + // Reached filesystem root without finding valid ancestor + throw new PathValidationError( + "Could not validate path ancestry within worktree", + "SYMLINK_ESCAPE", + ); +} + /** * Check if the resolved realpath stays within the worktree boundary. * Prevents symlink escape attacks where a symlink points outside the worktree. @@ -39,29 +172,39 @@ async function assertRealpathInWorktree( const real = await realpath(fullPath); const worktreeReal = await realpath(worktreePath); - // Ensure realpath is within worktree (with proper boundary check) - if (!real.startsWith(`${worktreeReal}/`) && real !== worktreeReal) { + // Use path.relative for safer boundary checking + if (!isPathWithinWorktree(worktreeReal, real)) { throw new PathValidationError( "File is a symlink pointing outside the worktree", "SYMLINK_ESCAPE", ); } } catch (error) { - // If realpath fails with ENOENT, file doesn't exist yet - that's OK for writes + // If realpath fails with ENOENT, file doesn't exist yet + // Validate the parent directory chain instead if (error instanceof Error && "code" in error && error.code === "ENOENT") { + await assertParentInWorktree(worktreePath, fullPath); return; } // Re-throw PathValidationError if (error instanceof PathValidationError) { throw error; } - // Other errors (permission denied, etc.) - let them propagate - throw error; + // Other errors (permission denied, etc.) - fail closed for security + throw new PathValidationError( + "Cannot validate file path", + "SYMLINK_ESCAPE", + ); } } export const secureFs = { /** * Read a file within a worktree. + * + * SECURITY: Enforces symlink-escape check. If the file is a symlink + * pointing outside the worktree, this will throw PathValidationError. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if file escapes worktree */ async readFile( worktreePath: string, @@ -70,11 +213,20 @@ export const secureFs = { ): Promise { assertRegisteredWorktree(worktreePath); const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block reads through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + return readFile(fullPath, encoding); }, /** * Read a file as a Buffer within a worktree. + * + * SECURITY: Enforces symlink-escape check. If the file is a symlink + * pointing outside the worktree, this will throw PathValidationError. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if file escapes worktree */ async readFileBuffer( worktreePath: string, @@ -82,6 +234,10 @@ export const secureFs = { ): Promise { assertRegisteredWorktree(worktreePath); const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block reads through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + return readFile(fullPath); }, @@ -165,10 +321,13 @@ export const secureFs = { /** * Check if a file is a symlink that points outside the worktree. * - * Use this to warn users when viewing files that resolve outside - * the worktree boundary (potential malicious repo symlink). + * WARNING: This is a best-effort helper for UI warnings only. + * It returns `false` on errors, so it is NOT suitable as a security gate. + * For security enforcement, use the read/write methods which call + * assertRealpathInWorktree internally. * - * @returns true if the file is a symlink escaping the worktree + * @returns true if the file is definitely a symlink escaping the worktree, + * false if not escaping OR if we can't determine (errors) */ async isSymlinkEscaping( worktreePath: string, @@ -188,9 +347,10 @@ export const secureFs = { const real = await realpath(fullPath); const worktreeReal = await realpath(worktreePath); - return !real.startsWith(`${worktreeReal}/`) && real !== worktreeReal; + return !isPathWithinWorktree(worktreeReal, real); } catch { // If we can't determine, assume not escaping (file may not exist) + // NOTE: This makes this method unsuitable as a security gate return false; } }, diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index acbfa3bae18..afbff9fc94b 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -11,7 +11,9 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; /** - * Zod schema for FileViewerState - matches shared/tabs-types.ts + * Zod schema for FileViewerState persistence. + * Note: initialLine/initialColumn from shared/tabs-types.ts are intentionally + * omitted as they are transient (applied once on open, not persisted). */ const fileViewerStateSchema = z.object({ filePath: z.string(), diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index e8a975f2ee4..11c2c491cc2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -136,6 +136,9 @@ export function FileViewerPane({ const diffCategory = fileViewer?.diffCategory; const commitHash = fileViewer?.commitHash; const oldPath = fileViewer?.oldPath; + // Line/column for initial scroll position (raw mode only, applied once) + const initialLine = fileViewer?.initialLine; + const initialColumn = fileViewer?.initialColumn; // Fetch branch info for against-base diffs (P1-1) const { data: branchData } = trpc.changes.getBranches.useQuery( @@ -147,6 +150,9 @@ export function FileViewerPane({ // Track if we're saving from raw mode to know when to clear draft const savingFromRawRef = useRef(false); + // Track if we've applied initial line/column navigation (reset on file change) + const hasAppliedInitialLocationRef = useRef(false); + // Save mutation const saveFileMutation = trpc.changes.saveFile.useMutation({ onSuccess: () => { @@ -247,12 +253,13 @@ export function FileViewerPane({ } }, []); - // Reset dirty state and draft when file changes + // Reset dirty state, draft, and initial location tracking when file changes // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only useEffect(() => { setIsDirty(false); originalContentRef.current = ""; draftContentRef.current = null; + hasAppliedInitialLocationRef.current = false; }, [filePath]); // Fetch raw file content - always call hook, use enabled to control fetching @@ -296,6 +303,39 @@ export function FileViewerPane({ } }, [rawFileData]); + // Apply initial line/column navigation when raw content is ready + // NOTE: Line/column navigation only supported in raw mode. + // Diff mode has different line numbers between sides; rendered mode has no line concept. + useEffect(() => { + if ( + viewMode !== "raw" || + !editorRef.current || + !initialLine || + hasAppliedInitialLocationRef.current || + isLoadingRaw || + !rawFileData?.ok + ) { + return; + } + + const editor = editorRef.current; + const model = editor.getModel(); + if (!model) return; + + // Clamp to valid range to handle lines that exceed file length + const lineCount = model.getLineCount(); + const safeLine = Math.max(1, Math.min(initialLine, lineCount)); + const maxColumn = model.getLineMaxColumn(safeLine); + const safeColumn = Math.max(1, Math.min(initialColumn ?? 1, maxColumn)); + + const position = { lineNumber: safeLine, column: safeColumn }; + editor.setPosition(position); + editor.revealPositionInCenter(position); + editor.focus(); + + hasAppliedInitialLocationRef.current = true; + }, [viewMode, initialLine, initialColumn, isLoadingRaw, rawFileData]); + // Early return AFTER hooks if (!fileViewer) { return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 2c73582c9af..bb4d776297e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1,3 +1,4 @@ +import { toast } from "@superset/ui/sonner"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; @@ -79,28 +80,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { (path: string, line?: number, column?: number) => { const behavior = terminalLinkBehavior ?? "external-editor"; - if (behavior === "file-viewer") { - // Normalize absolute paths to worktree-relative paths for file viewer - // File viewer expects relative paths, but terminal links can be absolute - let filePath = path; - if (workspaceCwd) { - // Use path boundary check to avoid incorrect prefix stripping - // e.g., /repo vs /repo-other should not match - if (path === workspaceCwd) { - filePath = "."; - } else if (path.startsWith(`${workspaceCwd}/`)) { - filePath = path.slice(workspaceCwd.length + 1); - } else if (path.startsWith("/")) { - // Absolute path outside workspace - still try to open it - // but warn in console as it may fail validation - console.warn( - "[Terminal] Opening absolute path outside workspace:", - path, - ); - } - } - addFileViewerPane(workspaceId, { filePath }); - } else { + // Helper to open in external editor + const openInExternalEditor = () => { trpcClient.external.openFileInEditor .mutate({ path, @@ -114,7 +95,43 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { path, error, ); + toast.error("Failed to open file in editor", { + description: path, + }); }); + }; + + if (behavior === "file-viewer") { + // If workspaceCwd is not loaded yet, fall back to external editor + // This prevents confusing errors when the workspace is still initializing + if (!workspaceCwd) { + console.warn( + "[Terminal] workspaceCwd not loaded, falling back to external editor", + ); + openInExternalEditor(); + return; + } + + // Normalize absolute paths to worktree-relative paths for file viewer + // File viewer expects relative paths, but terminal links can be absolute + let filePath = path; + // Use path boundary check to avoid incorrect prefix stripping + // e.g., /repo vs /repo-other should not match + if (path === workspaceCwd) { + filePath = "."; + } else if (path.startsWith(`${workspaceCwd}/`)) { + filePath = path.slice(workspaceCwd.length + 1); + } else if (path.startsWith("/")) { + // Absolute path outside workspace - show warning and don't attempt to open + toast.warning("File is outside the workspace", { + description: + "Switch to 'External editor' in Settings to open this file", + }); + return; + } + addFileViewerPane(workspaceId, { filePath, line, column }); + } else { + openInExternalEditor(); } }, [terminalLinkBehavior, workspaceId, workspaceCwd, addFileViewerPane], diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 6e6823d33e0..828d0679dc1 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -416,6 +416,8 @@ export const useTabsStore = create()( diffCategory: options.diffCategory, commitHash: options.commitHash, oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, }, }, }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 64a298ab958..9638df6e072 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -37,6 +37,10 @@ export interface AddFileViewerPaneOptions { diffCategory?: ChangeCategory; commitHash?: string; oldPath?: string; + /** Line to scroll to (raw mode only) */ + line?: number; + /** Column to scroll to (raw mode only) */ + column?: number; } /** diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 797e644d0c8..77eecc76c1f 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -99,6 +99,10 @@ export interface CreateFileViewerPaneOptions { diffCategory?: ChangeCategory; commitHash?: string; oldPath?: string; + /** Line to scroll to (raw mode only) */ + line?: number; + /** Column to scroll to (raw mode only) */ + column?: number; } /** @@ -130,6 +134,8 @@ export const createFileViewerPane = ( diffCategory: options.diffCategory, commitHash: options.commitHash, oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, }; // Use filename for display name diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index d38ce4c4284..d8c921186eb 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -38,6 +38,10 @@ export interface FileViewerState { commitHash?: string; /** Original path for renamed files */ oldPath?: string; + /** Initial line to scroll to (raw mode only, transient - applied once) */ + initialLine?: number; + /** Initial column to scroll to (raw mode only, transient - applied once) */ + initialColumn?: number; } /** diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index b5437dd783d..8c4daa138e6 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -107,6 +107,12 @@ export const workspaces = sqliteTable( index("workspaces_project_id_idx").on(table.projectId), index("workspaces_worktree_id_idx").on(table.worktreeId), index("workspaces_last_opened_at_idx").on(table.lastOpenedAt), + // NOTE: Migration 0006 creates an additional partial unique index: + // CREATE UNIQUE INDEX workspaces_unique_branch_per_project + // ON workspaces(project_id) WHERE type = 'branch' + // This enforces one branch workspace per project. Drizzle's schema DSL + // doesn't support partial/filtered indexes, so this constraint is only + // applied via the migration, not schema push. See migration 0006 for details. ], ); From ef01450fb2b6591e280b253138d02770ed4206ec Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 17:23:30 +0200 Subject: [PATCH 38/64] fix(desktop): address PR review security and UX issues - P0: Add dangling symlink validation to prevent writes outside worktree - P2: Fix isPathWithinWorktree to not incorrectly reject '..config' dirs - P1: Add symlink-escape error message in FileViewerPane - P1: Fix line/column navigation when clicking same file with new coords --- .../routers/changes/security/secure-fs.ts | 89 ++++++++++++++++--- .../TabView/FileViewerPane/FileViewerPane.tsx | 10 ++- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 4f68e28932e..c6de9823606 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -8,7 +8,7 @@ import { stat, writeFile, } from "node:fs/promises"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { assertRegisteredWorktree, PathValidationError, @@ -39,12 +39,18 @@ function isPathWithinWorktree( return true; } const relativePath = relative(worktreeReal, targetReal); - // If relative path starts with ".." or is absolute, it's outside - return ( - !relativePath.startsWith("..") && - !isAbsolute(relativePath) && - relativePath !== "" - ); + // Check if path escapes worktree: + // - ".." means direct parent + // - "../" prefix means ancestor escape (use sep for cross-platform) + // - Absolute path means completely outside + // Note: Don't use startsWith("..") as it incorrectly catches "..config" directories + const escapesWorktree = + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) || + relativePath === ""; + + return !escapesWorktree; } /** @@ -180,10 +186,10 @@ async function assertRealpathInWorktree( ); } } catch (error) { - // If realpath fails with ENOENT, file doesn't exist yet - // Validate the parent directory chain instead + // If realpath fails with ENOENT, the target doesn't exist + // But the path itself might be a dangling symlink - check that first! if (error instanceof Error && "code" in error && error.code === "ENOENT") { - await assertParentInWorktree(worktreePath, fullPath); + await assertDanglingSymlinkSafe(worktreePath, fullPath); return; } // Re-throw PathValidationError @@ -197,6 +203,69 @@ async function assertRealpathInWorktree( ); } } + +/** + * Handle the ENOENT case: check if fullPath is a dangling symlink pointing outside + * the worktree, or if it truly doesn't exist (in which case validate parent chain). + * + * Attack scenario this prevents: + * - Repo contains `docs/config.yml` → symlink to `~/.ssh/some_new_file` (doesn't exist) + * - realpath() fails with ENOENT (target missing) + * - Without this check, we'd only validate parent (`docs/`) which is valid + * - Write would follow symlink and create `~/.ssh/some_new_file` + * + * @throws PathValidationError if symlink escapes worktree + */ +async function assertDanglingSymlinkSafe( + worktreePath: string, + fullPath: string, +): Promise { + const worktreeReal = await realpath(worktreePath); + + try { + // Check if the path itself exists (as a symlink or otherwise) + const stats = await lstat(fullPath); + + if (stats.isSymbolicLink()) { + // It's a dangling symlink - validate where it points + const linkTarget = await readlink(fullPath); + const resolvedTarget = isAbsolute(linkTarget) + ? linkTarget + : resolve(dirname(fullPath), linkTarget); + + // Check if the resolved target would be within worktree + // For dangling symlinks, we can't use realpath on the target, + // so we check the literal resolved path + const targetRelative = relative(worktreeReal, resolvedTarget); + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { + throw new PathValidationError( + "Dangling symlink points outside the worktree", + "SYMLINK_ESCAPE", + ); + } + // Dangling symlink points within worktree - allow the operation + return; + } + + // Not a symlink but lstat succeeded - weird state, but validate parent chain + await assertParentInWorktree(worktreePath, fullPath); + } catch (error) { + if (error instanceof PathValidationError) { + throw error; + } + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + // Path truly doesn't exist (not even as a symlink) - validate parent chain + await assertParentInWorktree(worktreePath, fullPath); + return; + } + // Other errors - fail closed + throw new PathValidationError("Cannot validate path", "SYMLINK_ESCAPE"); + } +} export const secureFs = { /** * Read a file within a worktree. diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 11c2c491cc2..e33ce675d2a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -262,6 +262,12 @@ export function FileViewerPane({ hasAppliedInitialLocationRef.current = false; }, [filePath]); + // P1: Reset navigation flag when line/column changes (e.g., clicking same file from terminal with different line) + // biome-ignore lint/correctness/useExhaustiveDependencies: Only reset when coordinates change + useEffect(() => { + hasAppliedInitialLocationRef.current = false; + }, [initialLine, initialColumn]); + // Fetch raw file content - always call hook, use enabled to control fetching const { data: rawFileData, isLoading: isLoadingRaw } = trpc.changes.readWorkingFile.useQuery( @@ -471,7 +477,9 @@ export function FileViewerPane({ ? "Binary file preview not supported" : rawFileData?.reason === "outside-worktree" ? "File is outside worktree" - : "File not found"; + : rawFileData?.reason === "symlink-escape" + ? "File is a symlink pointing outside worktree" + : "File not found"; return (
{errorMessage} From b6c21eb27e40103febede4c447fb031ddd2fe5ad Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 17:40:03 +0200 Subject: [PATCH 39/64] fix(desktop): use sep-aware check in assertParentInWorktree ENOENT path P2 from review: The dangling symlink target check in assertParentInWorktree was still using startsWith('..') which incorrectly rejects '..config' dirs. Now uses the same pattern as isPathWithinWorktree. --- .../src/lib/trpc/routers/changes/security/secure-fs.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index c6de9823606..75201eb81f9 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -106,7 +106,12 @@ async function assertParentInWorktree( // We need to check if the target, when resolved, would be in worktree // This is conservative: if we can't determine, fail closed const targetRelative = relative(worktreeReal, resolvedTarget); - if (targetRelative.startsWith("..") || isAbsolute(targetRelative)) { + // Use sep-aware check to avoid false positives on "..config" dirs + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { throw new PathValidationError( "Dangling symlink points outside the worktree", "SYMLINK_ESCAPE", From 4fe2110b437a1a23ee98e1c4122437f95904d04f Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 18:35:34 +0200 Subject: [PATCH 40/64] fix(desktop): address P0/P1 security issues from review P0: secureFs.delete symlink escape - Check if target is symlink via lstat before deletion - Symlinks: delete the link itself (safe - lives in worktree) - Files/dirs: validate realpath is within worktree before rm - Prevents attack: docs -> /victim, delete docs/file deletes /victim/file P1: Block external images in markdown renderer - Add SafeImage component that blocks http:// and https:// URLs - Shows 'External image blocked' placeholder for external images - Allows relative paths and data: URLs (safe for repo content) - Prevents tracking pixels and privacy leaks from untrusted repos Design decisions (reviewer questions): - Markdown remote images: Blocked by default. No opt-in toggle yet. - Deleting symlinks: Allow deleting the symlink file itself (it lives in worktree). Never follow the symlink during deletion. --- .../routers/changes/security/secure-fs.ts | 39 +++++++++++++- .../components/SafeImage/SafeImage.tsx | 54 +++++++++++++++++++ .../components/SafeImage/index.ts | 1 + .../MarkdownRenderer/components/index.ts | 1 + .../styles/default/config.tsx | 8 ++- .../MarkdownRenderer/styles/tufte/config.tsx | 4 +- 6 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx create mode 100644 apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 75201eb81f9..6d72eb31109 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -341,8 +341,13 @@ export const secureFs = { /** * Delete a file or directory within a worktree. * - * DANGEROUS: Uses recursive + force deletion. - * Explicitly prevents deleting the worktree root. + * SECURITY: Validates the real path is within worktree before deletion. + * - Symlinks: Deletes the link itself (safe - link lives in worktree) + * - Files/dirs: Validates realpath then deletes + * + * This prevents symlink escape attacks where a malicious repo contains + * `docs -> /Users/victim` and a delete of `docs/file` would delete + * `/Users/victim/file`. */ async delete(worktreePath: string, filePath: string): Promise { assertRegisteredWorktree(worktreePath); @@ -350,6 +355,36 @@ export const secureFs = { const fullPath = resolvePathInWorktree(worktreePath, filePath, { allowRoot: false, }); + + let stats: Stats; + try { + stats = await lstat(fullPath); + } catch (error) { + // File doesn't exist - idempotent delete, nothing to do + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + return; + } + throw error; + } + + if (stats.isSymbolicLink()) { + // Symlink - safe to delete the link itself (it lives in the worktree). + // Don't use recursive as we're just removing the symlink file. + await rm(fullPath); + return; + } + + // Regular file or directory - validate realpath is within worktree. + // This catches path traversal via symlinked parent components: + // e.g., `docs -> /victim`, delete `docs/file` → realpath is `/victim/file` + await assertRealpathInWorktree(worktreePath, fullPath); + + // Safe to delete - realpath confirmed within worktree. + // Note: Symlinks INSIDE a directory are safe - rm deletes the links, not targets. await rm(fullPath, { recursive: true, force: true }); }, diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx new file mode 100644 index 00000000000..be5d07fcc21 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx @@ -0,0 +1,54 @@ +import { LuImageOff } from "react-icons/lu"; + +/** + * Check if a URL is external (http:// or https://). + * External images are blocked by default to prevent: + * - Tracking pixels leaking user IP/activity + * - Automatic downloads of large/malicious files + * - Internal network exposure + */ +function isExternalUrl(src: string | undefined): boolean { + if (!src) return false; + const lower = src.toLowerCase().trim(); + return lower.startsWith("http://") || lower.startsWith("https://"); +} + +interface SafeImageProps { + src?: string; + alt?: string; + className?: string; +} + +/** + * Safe image component that blocks external URLs by default. + * + * Allowed: + * - Relative paths (./image.png, ../assets/logo.svg) + * - Data URLs (data:image/png;base64,...) + * - File URLs (file://...) + * + * Blocked: + * - HTTP/HTTPS URLs (privacy risk from untrusted repos) + */ +export function SafeImage({ src, alt, className }: SafeImageProps) { + if (isExternalUrl(src)) { + return ( +
+ + External image blocked +
+ ); + } + + // Safe to render - relative path or data URL + return ( + {alt} + ); +} diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts new file mode 100644 index 00000000000..3a608bf50fd --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts @@ -0,0 +1 @@ +export { SafeImage } from "./SafeImage"; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts index b5cd6b0e8fd..d208845016c 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts @@ -1,2 +1,3 @@ export { CodeBlock } from "./CodeBlock"; +export { SafeImage } from "./SafeImage"; export { SelectionContextMenu } from "./SelectionContextMenu"; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx index 61ef8cdd6a0..c19cc5e1b78 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx @@ -1,5 +1,5 @@ import { cn } from "@superset/ui/utils"; -import { CodeBlock } from "../../components"; +import { CodeBlock, SafeImage } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./default.css"; @@ -41,7 +41,11 @@ export const defaultConfig: MarkdownStyleConfig = { ), img: ({ src, alt }) => ( - {alt} + ), hr: () =>
, li: ({ children, className }) => { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx index 077f8d91371..5173f7e4557 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx @@ -1,4 +1,4 @@ -import { CodeBlock } from "../../components"; +import { CodeBlock, SafeImage } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./tufte.css"; @@ -12,5 +12,7 @@ export const tufteConfig: MarkdownStyleConfig = { {children} ), + // Block external images for privacy (tracking pixels, etc.) + img: ({ src, alt }) => , }, }; From 4efee029ae75d3b57fad8f8824d0f9ae191d9167 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 18:42:08 +0200 Subject: [PATCH 41/64] fix(desktop): guard against undefined panes in attention check --- .../main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx | 2 +- .../WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index b98e36c98c2..8b3dd67e6b2 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -71,7 +71,7 @@ export function WorkspaceItem({ workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); const hasPaneAttention = Object.values(panes) - .filter((p) => workspacePaneIds.has(p.id)) + .filter((p) => p != null && workspacePaneIds.has(p.id)) .some((p) => p.needsAttention); // Show indicator if workspace is manually marked as unread OR has pane-level attention diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 9a1ca16b45d..04afd81d085 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -103,7 +103,7 @@ export function WorkspaceListItem({ workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); const hasPaneAttention = Object.values(panes) - .filter((p) => workspacePaneIds.has(p.id)) + .filter((p) => p != null && workspacePaneIds.has(p.id)) .some((p) => p.needsAttention); // Show indicator if workspace is manually marked as unread OR has pane-level attention From d068e92dc68662d2284999a449126f0e647d3c0a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sat, 3 Jan 2026 19:00:48 +0200 Subject: [PATCH 42/64] fix(desktop): use strict allowlist for SafeImage - only allow data: URLs P0: Previous fix only blocked http/https but missed: - file:// URLs (arbitrary local file access) - Absolute paths /... or \... (become file:// in Electron) - Relative paths with .. (escape repo boundary) - UNC paths //server/share (Windows NTLM credential leak) Now uses strict allowlist: ONLY data: URLs are allowed. All other sources show 'Image blocked' placeholder. Future: Could add opt-in secure loader for repo-relative images via secureFs validation + blob: URL serving. --- .../components/SafeImage/SafeImage.tsx | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx index be5d07fcc21..91295eaa98f 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx @@ -1,16 +1,33 @@ import { LuImageOff } from "react-icons/lu"; /** - * Check if a URL is external (http:// or https://). - * External images are blocked by default to prevent: - * - Tracking pixels leaking user IP/activity - * - Automatic downloads of large/malicious files - * - Internal network exposure + * Check if an image source is safe to load. + * + * Uses strict ALLOWLIST approach - only data: URLs are safe. + * + * ALLOWED: + * - data: URLs (embedded base64 images) + * + * BLOCKED (everything else): + * - http://, https:// (tracking pixels, privacy leak) + * - file:// URLs (arbitrary local file access) + * - Absolute paths /... or \... (become file:// in Electron) + * - Relative paths with .. (can escape repo boundary) + * - UNC paths //server/share (Windows NTLM credential leak) + * - Empty or malformed sources + * + * Security context: In Electron production, renderer loads via file:// + * protocol. Any non-data: image src could access local filesystem or + * trigger network requests to attacker-controlled servers. */ -function isExternalUrl(src: string | undefined): boolean { +function isSafeImageSrc(src: string | undefined): boolean { if (!src) return false; - const lower = src.toLowerCase().trim(); - return lower.startsWith("http://") || lower.startsWith("https://"); + const trimmed = src.trim(); + if (trimmed.length === 0) return false; + + // Only allow data: URLs (embedded images) + // These are self-contained and can't access external resources + return trimmed.toLowerCase().startsWith("data:"); } interface SafeImageProps { @@ -20,30 +37,30 @@ interface SafeImageProps { } /** - * Safe image component that blocks external URLs by default. + * Safe image component for untrusted markdown content. * - * Allowed: - * - Relative paths (./image.png, ../assets/logo.svg) - * - Data URLs (data:image/png;base64,...) - * - File URLs (file://...) + * Only renders embedded data: URLs. All other sources are blocked + * to prevent local file access, network requests, and path traversal + * attacks from malicious repository content. * - * Blocked: - * - HTTP/HTTPS URLs (privacy risk from untrusted repos) + * Future: Could add opt-in support for repo-relative images via a + * secure loader that validates paths through secureFs and serves + * as blob: URLs. */ export function SafeImage({ src, alt, className }: SafeImageProps) { - if (isExternalUrl(src)) { + if (!isSafeImageSrc(src)) { return (
- External image blocked + Image blocked
); } - // Safe to render - relative path or data URL + // Safe to render - embedded data: URL return ( Date: Sun, 4 Jan 2026 11:31:38 +0200 Subject: [PATCH 43/64] fix(desktop): format aria-label ternary for biome compliance --- .../screens/main/components/SidebarControl/SidebarControl.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index fb17d261ce7..35b2cd353d8 100644 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -14,7 +14,9 @@ export function SidebarControl() { variant="ghost" size="icon" onClick={toggleSidebar} - aria-label={isSidebarOpen ? "Hide Changes Sidebar" : "Show Changes Sidebar"} + aria-label={ + isSidebarOpen ? "Hide Changes Sidebar" : "Show Changes Sidebar" + } className="no-drag" > {isSidebarOpen ? ( From 04ea2a0b45a84bf2636408f0e05a2799b3ab3aaf Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 11:38:02 +0200 Subject: [PATCH 44/64] fix(desktop): address PR review feedback - Remove questionable 'unknown switch' error check in gitSwitchBranch (not a valid Git error message for missing commands) - Remove redundant 'relativePath === ""' check in isPathWithinWorktree (already handled by equality check above) - Use 'mod+' instead of 'meta+' for cross-platform keyboard shortcuts (maps to Cmd on macOS and Ctrl on Windows/Linux) - Add getActive query invalidation when toggling workspace unread state (ensures UI stays in sync across all queries) --- .../src/lib/trpc/routers/changes/security/git-commands.ts | 8 +++----- .../src/lib/trpc/routers/changes/security/secure-fs.ts | 4 ++-- apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts | 5 +++-- .../TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx | 2 ++ 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index 643c7826e6d..2877176b818 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -46,12 +46,10 @@ export async function gitSwitchBranch( // Prefer `git switch` - unambiguous branch operation (git 2.23+) await git.raw(["switch", branch]); } catch (switchError) { - // Check if it's because `switch` command doesn't exist (old git) + // Check if it's because `switch` command doesn't exist (old git < 2.23) + // Git outputs: "git: 'switch' is not a git command. See 'git --help'." const errorMessage = String(switchError); - if ( - errorMessage.includes("is not a git command") || - errorMessage.includes("unknown switch") - ) { + if (errorMessage.includes("is not a git command")) { // Fallback for older git versions // Note: checkout WITHOUT -- is correct for branches await git.checkout(branch); diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 6d72eb31109..27d7ac2b89b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -44,11 +44,11 @@ function isPathWithinWorktree( // - "../" prefix means ancestor escape (use sep for cross-platform) // - Absolute path means completely outside // Note: Don't use startsWith("..") as it incorrectly catches "..config" directories + // Note: Empty relativePath ("") case is already handled by the equality check above const escapesWorktree = relativePath === ".." || relativePath.startsWith(`..${sep}`) || - isAbsolute(relativePath) || - relativePath === ""; + isAbsolute(relativePath); return !escapesWorktree; } diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index a37a8684377..d69aae5ad5e 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -65,10 +65,11 @@ export function useWorkspaceShortcuts() { // Flatten workspaces for keyboard navigation const allWorkspaces = groups.flatMap((group) => group.workspaces); - // Workspace switching shortcuts (⌘+1-9) + // Workspace switching shortcuts (⌘/Ctrl+1-9) + // Using "mod" maps to Cmd on macOS and Ctrl on Windows/Linux const workspaceKeys = Array.from( { length: 9 }, - (_, i) => `meta+${i + 1}`, + (_, i) => `mod+${i + 1}`, ).join(", "); const handleWorkspaceSwitch = useCallback( diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx index 81b8b6ce181..fbba24e3362 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -40,7 +40,9 @@ export function WorkspaceItemContextMenu({ const openInFinder = trpc.external.openInFinder.useMutation(); const setUnread = trpc.workspaces.setUnread.useMutation({ onSuccess: () => { + // Invalidate both queries that return isUnread state utils.workspaces.getAllGrouped.invalidate(); + utils.workspaces.getActive.invalidate(); }, }); From 15b491986c07bd2e4a321b23f24308995b07ecf8 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 11:57:58 +0200 Subject: [PATCH 45/64] chore: remove outdated plan files These files diverged from the main branch structure and are no longer needed. --- .agents/commands/create-plan-file.md | 271 ------- ...51231-1200-workspace-sidebar-navigation.md | 691 ------------------ 2 files changed, 962 deletions(-) delete mode 100644 .agents/commands/create-plan-file.md delete mode 100644 apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md diff --git a/.agents/commands/create-plan-file.md b/.agents/commands/create-plan-file.md deleted file mode 100644 index 3b092d269ed..00000000000 --- a/.agents/commands/create-plan-file.md +++ /dev/null @@ -1,271 +0,0 @@ -# Superset Execution Plans (ExecPlans): - -> **DO NOT EDIT THIS FILE** -> This file is the ExecPlan template and guide only. -> Create plans in the appropriate location: -> - **App-specific work**: `apps//.agents/plans/-.md` -> - **Package work**: `packages//.agents/plans/-.md` -> - **Cross-app/shared work**: `.agents/plans/-.md` (root) - -This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context. - -## Process - -Steps: -1. Discovery & Orientation: map the repo, name the scope, enumerate unknowns. Capture initial Assumptions and Open Questions in the ExecPlan. -2. Question-driven Clarification: ask focused, acceptance-oriented questions grouped by plan section. Maintain the Open Questions list in the ExecPlan and pre-link each item to a Decision Log placeholder. -3. Draft the Plan: complete the ExecPlan skeleton end-to-end (Purpose, Context, Plan of Work, Validation, Idempotence, etc.), calling out risks and dependencies. -4. Resolve Questions: as answers arrive, immediately update the ExecPlan—move items from Open Questions to the Decision Log with rationale; adjust Plan of Work and Acceptance accordingly. -5. Approval Gate: present the updated ExecPlan for approval. Do not implement until approved. -6. Implementation & Validation: implement per the plan, update Progress with timestamps, and validate via tests and acceptance. Log learnings in Surprises & Discoveries. -7. Closeout: write Outcomes & Retrospective; ensure the plan remains self-contained and accurate. -8. Write your plan to the appropriate location: - - App-specific: `apps//.agents/plans/-.md` - - Package-specific: `packages//.agents/plans/-.md` - - Cross-app: `.agents/plans/-.md` - Use `` in `YYYYMMDD-HHmm` format (e.g., `20240613-1045-my-feature-plan.md`). This ensures plans are sorted from most recent to oldest. -9. Plan Lifecycle: When the plan is complete and a PR is created, move it to the `done/` folder within the same directory. If abandoned, move it to `abandoned/`. - -Example questions: -``` -I reviewed the existing auth implementation in apps/web/src/app/auth/. - -Where should the new OAuth provider live? -a) apps/web/src/lib/auth/providers/ (co-located with auth logic) -b) packages/shared/src/auth/ (shared across apps) -c) Other (specify) - -How should we handle token refresh? -a) Silent refresh via interceptor -b) Explicit refresh on 401 response -c) Other (specify) -``` - -## How to use ExecPlans and PLANS.md - -When authoring an executable specification (ExecPlan), follow this document _to the letter_. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. - -When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently. - -When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. - -When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation. - -## Requirements - -NON-NEGOTIABLE REQUIREMENTS: - -* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed. -* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained. -* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo. -* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition". -* Every ExecPlan must define every term of art in plain language or do not use it. - -Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe. - -The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan. - -## Formatting - -Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists. - -When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks. - -Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first. - -## Guidelines - -Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself. - -Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details. - -Question discipline and placement: -- Keep each question atomic and acceptance-oriented (what behavior must hold? how will we observe it?). -- Record questions in an Open Questions section of the ExecPlan; tag each with the plan section it affects (e.g., Validation, Plan of Work). -- When a question is answered, create a Decision Log entry with rationale and update the affected sections. Remove the item from Open Questions. -- Prefer at most 3-7 active questions; timebox low-impact ones or convert them into explicit Assumptions. - -Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to http://localhost:3000/health returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior). - -Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable. - -Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go. - -Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project's toolchain and how to interpret their results. - -## Superset-Specific Context - -This is a Bun + Turborepo monorepo with the following structure: - -**Apps:** -- `apps/web` - Main web application (app.superset.sh) -- `apps/marketing` - Marketing site (superset.sh) -- `apps/admin` - Admin dashboard -- `apps/api` - API backend -- `apps/desktop` - Electron desktop application -- `apps/docs` - Documentation site -- `apps/cli` - CLI tooling - -**Packages:** -- `packages/ui` - Shared UI components (shadcn/ui + TailwindCSS v4) -- `packages/db` - Drizzle ORM database schema -- `packages/local-db` - Local database schema -- `packages/queries` - Shared query logic -- `packages/shared` - Shared constants and utilities -- `packages/trpc` - tRPC configuration - -**Common Commands:** -- `bun dev` - Start all dev servers -- `bun test` - Run tests -- `bun build` - Build all packages -- `bun run lint` - Check for lint issues -- `bun run lint:fix` - Fix auto-fixable lint issues -- `bun run typecheck` - Type check all packages -- `bun run db:push` - Apply schema changes -- `bun run db:migrate` - Run migrations - -Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs. - -## Milestones - -Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation. - -Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan. - -## Living plans and design decisions - -* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section. -* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional. -* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal). -* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you. -* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned. - -# Prototyping milestones and parallel implementations - -It is acceptable--and often encouraged--to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as "prototyping"; describe how to run and observe results; and state the criteria for promoting or discarding the prototype. - -Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation. - -## Skeleton of a Good ExecPlan - -```md -# - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -## Purpose / Big Picture - -Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable. - -## Assumptions - -State temporary assumptions that unblock planning. Every assumption must either be confirmed (moved to the Decision Log) or removed by implementation end. - -## Open Questions - -List unresolved, acceptance-oriented questions. For each, note the impacted plan sections (e.g., Validation, Plan of Work) and add a placeholder in the Decision Log for the eventual answer. - -## Progress - -Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two ("done" vs. "remaining"). This section must always reflect the actual current state of the work. - -- [x] (2025-10-01 13:00Z) Example completed step. -- [ ] Example incomplete step. -- [ ] Example partially completed step (completed: X; remaining: Y). - -Use timestamps to measure rates of progress. - -## Surprises & Discoveries - -Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence. - -- Observation: ... - Evidence: ... - -## Decision Log - -Record every decision made while working on the plan in the format: - -- Decision: ... - Rationale: ... - Date/Author: ... - -## Outcomes & Retrospective - -Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose. - -## Context and Orientation - -Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans. - -## Plan of Work - -Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal. - -## Concrete Steps - -State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds. - -## Validation and Acceptance - -Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run `bun test` and expect passed; the new test fails before the change and passes after". - -## Idempotence and Recovery - -If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion. - -## Artifacts and Notes - -Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success. - -## Interfaces and Dependencies - -Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `packages/db/src/schema/users.ts` or `apps/web/src/lib/auth.ts`. E.g.: - -In packages/db/src/schema/users.ts, define: - - export const users = pgTable('users', { - id: text('id').primaryKey(), - email: text('email').notNull().unique(), - createdAt: timestamp('created_at').defaultNow(), - }); -``` - -If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED. - -When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything. - -## Plan Lifecycle - -ExecPlans have a defined lifecycle that keeps the `.agents/plans/` folder clean and provides a historical record of completed work. - -### Directory Structure - -``` -apps//.agents/plans/ # App-specific plans - .md - done/ - abandoned/ - -packages//.agents/plans/ # Package-specific plans - .md - done/ - abandoned/ - -.agents/plans/ # Cross-app/shared plans - .md - done/ - abandoned/ -``` - -### When to Move Plans - -**To `done/`**: Move the plan to the `done/` folder within the same directory when creating a PR that completes the work. Before moving, ensure the plan's `Outcomes & Retrospective` section is filled in. - -**To `abandoned/`**: Move the plan to the `abandoned/` folder within the same directory if work is stopped without completion. Add a note explaining why (scope changed, approach invalidated, deprioritized, etc.). - -### Edge Cases - -- **PR closed without merging**: The plan stays in `done/`. If work resumes, move it back to the active plans folder and update the `Progress` section. -- **Plan spans multiple PRs**: Keep the plan in the active folder until the final PR. Reference intermediate PRs in the `Progress` section, then move to `done/` on the final PR. -- **Reopening abandoned work**: Move the plan from `abandoned/` back to the active plans folder and update the `Progress` section to reflect the restart. diff --git a/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md b/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md deleted file mode 100644 index 88aba9783e4..00000000000 --- a/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md +++ /dev/null @@ -1,691 +0,0 @@ -# Configurable Workspace Navigation: Sidebar Mode - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -## Purpose / Big Picture - -Currently, workspaces are displayed as horizontal tabs in the TopBar, grouped by project. This change allows users to configure an alternative "sidebar" navigation style where workspaces appear in a dedicated left sidebar panel, matching designs from tools like Linear/GitHub Desktop. - -After this change, users can: -1. Open Settings > Behavior and toggle "Navigation style" between "Top bar" and "Sidebar" -2. In sidebar mode, see a dedicated workspace sidebar with collapsible project sections -3. Switch between workspaces by clicking items in the sidebar -4. See PR status, diff stats, and keyboard shortcuts inline with workspace items -5. Continue using ⌘1-9 shortcuts to switch workspaces regardless of mode - -## Design Reference - -The target design (based on provided mockup): - - ┌─────────────────────────────────────────────────────────────────────┐ - │ [Sidebar Toggle] [Workbench|Review] [Branch ▾] [Open In ▾] [Avatar]│ <- TopBar (sidebar mode) - ├──────────────────────┬──────────────────────────────────────────────┤ - │ ≡ Workspaces │ │ - │ │ │ - │ web │ │ - │ + New workspace ... │ Main Content Area │ - │ ┃ andreasasprou/cebu │ (Workbench or Review mode) │ - │ cebu · PR #144 │ │ - │ Ready to merge ⌘1 │ │ - │ +1850 -301 │ │ - │ │ │ - │ ▸ andreasasprou/feat │ │ - │ harare · PR #107 │ │ - │ Merge conflicts ⌘2 │ │ - │ ├──────────────────────────────────────────────┤ - │ nova │ │ - │ + New workspace ... │ Changes Sidebar │ - │ ┃ andreasasprou/pdf │ (existing ResizableSidebar) │ - │ la-paz-v2 · PR#720 │ │ - │ Uncommitted ⌘3 │ │ - │ +23823 -5 │ │ - │ │ │ - │ frontend │ │ - │ + New workspace ... │ │ - │ │ │ - │──────────────────── │ │ - │ [+] Add project │ │ - └──────────────────────┴──────────────────────────────────────────────┘ - Workspace Changes Content - Sidebar Sidebar (Mosaic Panes) - (NEW) (existing) - -Key visual elements: -- Active workspace: Green/project-colored left border (┃) -- Status badges: "Ready to merge", "Merge conflicts", "Uncommitted changes", "Archive" -- Diff stats: +insertions -deletions (always visible for active, hover for others) -- Keyboard shortcuts: ⌘1-9 displayed inline -- Collapsible project sections with header + "..." context menu -- "+ New workspace" per project section -- "Add project" at bottom footer - -## Assumptions - -1. The existing `WorkspaceHoverCard` already fetches PR status via `workspaces.getGitHubStatus` and can be reused -2. The `feat/desktop-workbench-review-mode` branch changes are the baseline (already rebased) -3. Users will primarily use one mode or the other, not switch frequently -4. The `packages/local-db` migration system handles schema changes on app startup - -## Open Questions - -(All questions resolved - see Decision Log) - -## Progress - -- [ ] Initial plan created and awaiting approval -- [ ] (Pending) Milestone 1: Add navigation style setting -- [ ] (Pending) Milestone 2: Create WorkspaceSidebar component -- [ ] (Pending) Milestone 3: Create sidebar-mode TopBar variant -- [ ] (Pending) Milestone 4: Wire up setting to conditionally render layouts -- [ ] (Pending) Milestone 5: Polish and validation - -## Surprises & Discoveries - -(To be filled during implementation) - -## Decision Log - -- **Decision**: Navigation style setting stored in SQLite settings table via existing tRPC pattern - - Rationale: Matches existing "confirmOnQuit" behavior setting pattern, persists across sessions - - Date: 2025-12-31 / Planning phase - -- **Decision**: Workspace sidebar is a NEW dedicated sidebar, not a mode in existing ModeCarousel - - Rationale: User preference for dedicated panel, keeps workspaces separate from terminal tabs/changes - - Date: 2025-12-31 / Planning phase - -- **Decision**: Both sidebars independently resizable - - Rationale: User may want different widths for workspace nav vs file changes - - Date: 2025-12-31 / Planning phase - -- **Decision**: ⌘1-9 shortcuts work in both navigation modes - - Rationale: Consistency for keyboard users regardless of UI layout preference - - Date: 2025-12-31 / Planning phase - -- **Decision**: Manual testing only, no automated tests for initial release - - Rationale: Feature is primarily UI/layout, visual verification more appropriate - - Date: 2025-12-31 / Planning phase - -- **Decision**: Workspace sidebar width persisted independently from changes sidebar - - Rationale: Users may want different widths for workspace nav vs file changes - - Date: 2025-12-31 / Planning phase - -- **Decision**: Workspace display format is "github-username/branch-name" (e.g., "andreasasprou/cebu") - - Rationale: Matches GitHub PR branch naming, provides author context - - Date: 2025-12-31 / Planning phase - -- **Decision**: Skip "Archive" status badge for initial release - - Rationale: Archive feature doesn't exist in app, can add later if needed - - Date: 2025-12-31 / Planning phase - -- **Decision**: Keep Workbench/Review toggle and Open In in WorkspaceActionBar, not TopBar - - Rationale: Avoids duplicating components, maintains consistent location across navigation modes - - Date: 2025-12-31 / Planning phase (review feedback) - -- **Decision**: Sidebar toggles use distinct naming: "Workspaces" and "Files" with different icons - - Rationale: With two sidebars, "Toggle sidebar" is ambiguous. Clear naming prevents confusion - - Date: 2025-12-31 / Planning phase (review feedback) - -- **Decision**: Workspace sidebar is toggleable (not always-on), default open on first use - - Rationale: Matches changes sidebar pattern, provides flexibility for screen sizes - - Date: 2025-12-31 / Planning phase (review feedback) - -- **Decision**: Use `workspaces.getGitHubStatus` for diff stats, lazy-load on hover - - Rationale: Reuses existing infrastructure, avoids N+1 queries, matches WorkspaceHoverCard - - Date: 2025-12-31 / Planning phase (review feedback) - -- **Decision**: Extract ⌘1-9 shortcuts and auto-create workspace logic into shared hook - - Rationale: These behaviors must work in BOTH navigation modes, avoiding code duplication - - Date: 2025-12-31 / Planning phase (review feedback) - -## Outcomes & Retrospective - -(To be filled at completion) - ---- - -## Context and Orientation - -### Current Architecture - -The desktop app (`apps/desktop/`) uses: - -**Layout Structure** (in `src/renderer/screens/main/`): -- `MainScreen` - Root component, manages view state (workspace/settings/tasks) -- `TopBar` - Contains `WorkspacesTabs` for horizontal workspace navigation -- `WorkspaceView` - Main content area with `ResizableSidebar` (changes) + `ContentView` - -**State Management**: -- `sidebar-state.ts` - Zustand store for changes sidebar (width, visibility, mode) -- `workspace-view-mode.ts` - Zustand store for Workbench/Review mode per workspace -- `app-state.ts` - Current view, settings section, etc. - -**Settings System**: -- Settings stored in SQLite via `packages/local-db/src/schema/schema.ts` -- tRPC routes in `src/lib/trpc/routers/settings/` -- UI in `src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` - -**Key Files**: -- `src/renderer/screens/main/components/TopBar/index.tsx` - Current TopBar -- `src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx` - Horizontal tabs -- `src/renderer/screens/main/components/WorkspaceView/index.tsx` - Main workspace layout -- `src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/` - Existing sidebar - -### Terminology - -- **Navigation style**: User preference for workspace display location ("top-bar" or "sidebar") -- **Workspace sidebar**: NEW left panel showing workspaces grouped by project -- **Changes sidebar**: EXISTING left panel showing git changes (file tree) -- **Workbench mode**: Terminal panes + file viewers (mosaic layout) -- **Review mode**: Full-page changes/diff view - ---- - -## Plan of Work - -### Milestone 1: Add Navigation Style Setting - -Add the setting infrastructure following the existing "confirmOnQuit" pattern. - -**1.1 Add setting to database schema** - -In `packages/local-db/src/schema/schema.ts`, add to settings table: - - navigationStyle: text("navigation_style").$type<"top-bar" | "sidebar">(), - -**1.2 Generate local-db migration** - -Run from `packages/local-db`: - - pnpm drizzle-kit generate --name="add_navigation_style" - -This creates a migration file in `packages/local-db/drizzle/`. The migration runs automatically on app startup via `apps/desktop/src/main/lib/local-db/index.ts` migrate logic. - -**IMPORTANT**: Do NOT use `bun run db:push` - that targets packages/db (Neon/Postgres), not local-db. - -**1.3 Add default constant** - -In `apps/desktop/src/shared/constants.ts`: - - export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; - export type NavigationStyle = "top-bar" | "sidebar"; - -**1.4 Add tRPC routes** - -In `apps/desktop/src/lib/trpc/routers/settings/index.ts`, add: - - getNavigationStyle: publicProcedure.query(async () => { - const row = getSettings(); - return row.navigationStyle ?? DEFAULT_NAVIGATION_STYLE; - }), - - setNavigationStyle: publicProcedure - .input(z.object({ style: z.enum(["top-bar", "sidebar"]) })) - .mutation(async ({ input }) => { - localDb.insert(settings) - .values({ id: 1, navigationStyle: input.style }) - .onConflictDoUpdate({ - target: settings.id, - set: { navigationStyle: input.style } - }) - .run(); - return { success: true }; - }), - -**1.5 Add UI in BehaviorSettings** - -In `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx`, add a toggle/select for "Navigation style" with options "Top bar" and "Sidebar". - -### Milestone 2: Create WorkspaceSidebar Component - -Create the new sidebar component matching the design. - -**2.1 Create store for workspace sidebar state** - -Create `apps/desktop/src/renderer/stores/workspace-sidebar-state.ts`: - - interface WorkspaceSidebarState { - isOpen: boolean; - width: number; - // Use string[] instead of Set for JSON serialization with Zustand persist - collapsedProjectIds: string[]; - toggleOpen: () => void; - setWidth: (width: number) => void; - toggleProjectCollapsed: (projectId: string) => void; - isProjectCollapsed: (projectId: string) => boolean; - } - -**NOTE**: Do NOT use `Set` for `collapsedProjectIds` - Zustand persist uses JSON serialization which drops Sets. Use `string[]` and provide helper methods. - -**2.2 Create component structure** - -Create folder: `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/` - -Files to create: -- `index.tsx` - Main component -- `WorkspaceSidebarHeader.tsx` - "Workspaces" header with icon -- `ProjectSection/ProjectSection.tsx` - Collapsible project group -- `ProjectSection/ProjectHeader.tsx` - Project name + actions -- `WorkspaceListItem/WorkspaceListItem.tsx` - Individual workspace row -- `WorkspaceListItem/WorkspaceStatusBadge.tsx` - Status badges -- `WorkspaceListItem/WorkspaceDiffStats.tsx` - +/- diff display -- `WorkspaceSidebarFooter.tsx` - "Add project" button - -**2.3 WorkspaceListItem design** - -Each workspace item displays: -- Left border (project color when active) -- Branch icon (git-branch, git-pull-request, etc. based on type) -- Author/branch: "andreasasprou/feature-name" -- Worktree name + PR info: "worktree-city · PR #123" -- Status badge: "Ready to merge" / "Merge conflicts" / "Uncommitted changes" / "Archive" -- Keyboard shortcut badge: "⌘1" -- Diff stats (for active): "+1850 -301" - -**2.4 Data fetching** - -Reuse existing queries: -- `trpc.workspaces.getAllGrouped.useQuery()` for project/workspace list -- `trpc.workspaces.getActive.useQuery()` for active workspace - -**Diff stats source**: Use `workspaces.getGitHubStatus` (already used by WorkspaceHoverCard) for PR additions/deletions. This is the authoritative source. Do NOT add a new git diff endpoint. For workspaces without PRs, show local uncommitted changes count from the changes router as fallback. - -**Performance consideration**: Avoid N+1 `getGitHubStatus` calls per workspace row. Options: -1. Extend `getAllGrouped` to include a summary `githubStatus` field (batched) -2. Reuse cached data from `worktrees.githubStatus` if already fetched -3. Lazy-load status on hover only (simplest, matches current WorkspaceHoverCard behavior) - -Recommended: Start with option 3 (lazy-load on hover) to match existing patterns, then optimize with batching if performance is an issue. - -**2.5 Extract shared workspace behaviors** - -Currently `WorkspacesTabs/index.tsx` owns critical behaviors that must work in BOTH navigation modes: -- ⌘1-9 workspace switching shortcuts -- Auto-create main workspace for new projects effect - -Create a shared hook: `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts` - - export function useWorkspaceShortcuts() { - const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); - const setActiveWorkspace = useSetActiveWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); - - // Flatten workspaces for ⌘1-9 navigation - const allWorkspaces = groups.flatMap((group) => group.workspaces); - - // ⌘1-9 shortcuts - useHotkeys(workspaceKeys, handleWorkspaceSwitch); - useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); - useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); - - // Auto-create main workspace for new projects - useEffect(() => { /* existing logic */ }, [groups]); - - return { allWorkspaces }; - } - -Then use this hook in BOTH: -- `WorkspaceSidebar/index.tsx` (sidebar mode) -- `WorkspacesTabs/index.tsx` (top-bar mode) - -This ensures shortcuts work regardless of navigation style. - -### Milestone 3: Create Sidebar-Mode TopBar Variant - -When navigation style is "sidebar", the TopBar should show a unified bar without workspace tabs. - -**3.1 Decide on control placement** - -Currently, `WorkspaceActionBar` contains: -- ViewModeToggle (Workbench/Review) -- Branch selector -- Open In dropdown - -**Decision needed**: In sidebar mode, do these controls: -A) Stay in WorkspaceActionBar (below TopBar) - no duplication, consistent location -B) Move to TopBarSidebarMode - more prominent, frees up vertical space - -**Recommendation**: Keep controls in WorkspaceActionBar (option A). This: -- Avoids duplicating components -- Maintains consistent location across modes -- Keeps TopBar focused on navigation - -TopBarSidebarMode then only needs: -- Changes sidebar toggle (existing SidebarControl, renamed for clarity) -- Workspace sidebar toggle (new) -- Avatar/user menu - -**3.2 Sidebar toggle disambiguation** - -With two sidebars, we need clear naming: -- **"Files" / file icon**: Toggle changes sidebar (existing, currently just "sidebar") -- **"Workspaces" / layers icon**: Toggle workspace sidebar (new) - -Update tooltips and potentially add labels on hover. Both toggles live in TopBar. - -**3.3 Create TopBarSidebarMode component** - -Create `apps/desktop/src/renderer/screens/main/components/TopBar/TopBarSidebarMode.tsx`: - -Layout (left to right): -- Workspace sidebar toggle (new, tooltip: "Toggle workspaces") -- Changes sidebar toggle (existing SidebarControl, tooltip: "Toggle files") -- [Spacer] -- [Right] Avatar dropdown - -The Workbench/Review toggle, branch selector, and Open In dropdown remain in WorkspaceActionBar. - -**3.4 Workspace sidebar always-on vs toggleable** - -The workspace sidebar should be toggleable (not always-on) because: -- Users may want full-width content when not switching workspaces -- Matches the existing changes sidebar pattern -- Provides flexibility for different screen sizes - -Default state: Open (on first use), then persisted via Zustand. - -**3.5 Conditional rendering in TopBar** - -Modify `apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx`: - - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - - if (navigationStyle === "sidebar") { - return ; - } - - return ; // Rename current implementation - -### Milestone 4: Wire Up Layout Switching - -Connect the setting to conditionally render the appropriate layout. - -**4.1 Modify MainScreen layout** - -In `apps/desktop/src/renderer/screens/main/index.tsx`, when rendering workspace view: - - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - - // In render: - {navigationStyle === "sidebar" && } - - -**4.2 Modify WorkspaceView** - -The `WorkspaceView` component remains largely unchanged - it already has the ResizableSidebar (changes) and ContentView. The WorkspaceSidebar sits to its left. - -**4.3 Layout structure in sidebar mode** - -
- {/* NEW - workspace navigation */} - {/* EXISTING - contains changes sidebar + content */} -
- -### Milestone 5: Polish and Validation - -**5.1 Keyboard shortcuts** - -Ensure ⌘1-9 workspace switching works in both modes. The existing `useHotkeys` in `WorkspacesTabs/index.tsx` should be moved/shared. - -**5.2 Hover preview** - -Implement hover preview showing branch + PR status. Can reuse `WorkspaceHoverCard` component or adapt it. - -**5.3 Animations** - -- Smooth sidebar show/hide with Framer Motion -- Collapse/expand project sections with animation -- Active workspace indicator transition - -**5.4 Persistence** - -- Workspace sidebar width persists (Zustand + localStorage) -- Collapsed project sections persist -- Navigation style persists (SQLite) - ---- - -## Concrete Steps - -All commands run from repository root: `/Users/andreasasprou/.superset/worktrees/superset/workspace-sidebar` - -**Step 1: Verify current state** - - cd apps/desktop - bun run typecheck - -Expected: No type errors (baseline) - -**Step 2: Add database schema field and generate migration** - -Edit `packages/local-db/src/schema/schema.ts` to add `navigationStyle` column. - - cd packages/local-db - pnpm drizzle-kit generate --name="add_navigation_style" - -Expected: Migration file created in `packages/local-db/drizzle/` - -The migration runs automatically on app startup. Do NOT use `bun run db:push` (that's for Neon/Postgres). - -**Step 3: Add setting routes** - -Edit `apps/desktop/src/lib/trpc/routers/settings/index.ts` - - bun run typecheck - -Expected: Types pass with new routes - -**Step 4: Create WorkspaceSidebar component** - -Create component files as specified in Milestone 2. - -**Step 5: Add to layout** - -Wire up conditional rendering in MainScreen. - - bun dev - -Expected: App starts, can toggle setting, layout switches - -**Step 6: Full validation** - - bun run lint:fix - bun run typecheck - bun test - -Expected: All pass - ---- - -## Validation and Acceptance - -### Manual Testing Checklist - -1. **Setting toggle works** - - Open Settings > Behavior - - See "Navigation style" option - - Toggle between "Top bar" and "Sidebar" - - Layout changes immediately (or after brief transition) - -2. **Sidebar mode displays correctly** - - WorkspaceSidebar appears on left - - Projects shown as collapsible sections - - Workspaces listed under each project - - Active workspace has colored left border - - Status badges visible - - Diff stats visible for active workspace - - ⌘1-9 shortcuts displayed - -3. **Interactions work** - - Click workspace to switch - - Click project header to collapse/expand - - Hover shows preview card - - ⌘1-9 switches workspaces - - "+ New workspace" opens creation dialog - - "Add project" opens project creation - -4. **TopBar adapts** - - In sidebar mode: No workspace tabs, unified bar with Workbench/Review toggle - - In top-bar mode: Original layout preserved - -5. **Persistence** - - Close and reopen app - - Navigation style preserved - - Sidebar widths preserved - - Collapsed projects preserved - -6. **Both sidebars coexist** - - Workspace sidebar (left) - - Changes sidebar (right of workspace sidebar) - - Both independently resizable - - Both can be toggled independently - ---- - -## Idempotence and Recovery - -- Database schema changes are additive (new nullable column) -- Running `db:push` multiple times is safe -- Component files are new additions, no destructive changes -- Setting defaults to "top-bar" if not set (backwards compatible) -- If implementation fails partway, the existing top-bar mode continues working - ---- - -## Artifacts and Notes - -### Component File Structure - - apps/desktop/src/renderer/ - ├── hooks/ - │ └── useWorkspaceShortcuts.ts (new - shared ⌘1-9 + auto-create logic) - ├── screens/main/components/ - │ ├── WorkspaceSidebar/ - │ │ ├── index.tsx - │ │ ├── WorkspaceSidebarHeader.tsx - │ │ ├── WorkspaceSidebarFooter.tsx - │ │ ├── ResizableWorkspaceSidebar.tsx (wrapper with resize handle) - │ │ ├── ProjectSection/ - │ │ │ ├── ProjectSection.tsx - │ │ │ ├── ProjectHeader.tsx - │ │ │ └── index.ts - │ │ └── WorkspaceListItem/ - │ │ ├── WorkspaceListItem.tsx - │ │ ├── WorkspaceStatusBadge.tsx - │ │ ├── WorkspaceDiffStats.tsx - │ │ └── index.ts - │ └── TopBar/ - │ ├── index.tsx (modified - conditional render) - │ ├── TopBarSidebarMode.tsx (new) - │ ├── TopBarDefault.tsx (renamed from inline JSX) - │ ├── SidebarControl.tsx (updated tooltip: "Toggle files") - │ ├── WorkspaceSidebarControl.tsx (new - "Toggle workspaces") - │ └── ... (existing files) - └── stores/ - └── workspace-sidebar-state.ts (new) - -### State Structure - - // workspace-sidebar-state.ts (Zustand + persist) - { - isOpen: true, - width: 280, // pixels - collapsedProjectIds: ["project-id-1", "project-id-2"], // string[] NOT Set - } - - // settings table (SQLite via local-db) - { - navigationStyle: "sidebar" | "top-bar" - } - -**Important**: Use `string[]` for `collapsedProjectIds`, not `Set`. Zustand persist uses JSON serialization which drops Sets. - ---- - -## Interfaces and Dependencies - -### New tRPC Routes - -In `apps/desktop/src/lib/trpc/routers/settings/index.ts`: - - getNavigationStyle: publicProcedure.query(() => NavigationStyle) - setNavigationStyle: publicProcedure.input({ style: NavigationStyle }).mutation() - -### New Zustand Store - -In `apps/desktop/src/renderer/stores/workspace-sidebar-state.ts`: - - export const useWorkspaceSidebarStore = create()( - devtools( - persist( - (set, get) => ({ - isOpen: true, - width: 280, - collapsedProjectIds: [], // string[] for JSON serialization - - toggleOpen: () => set((s) => ({ isOpen: !s.isOpen })), - - setWidth: (width) => set({ width }), - - toggleProjectCollapsed: (projectId) => - set((s) => ({ - collapsedProjectIds: s.collapsedProjectIds.includes(projectId) - ? s.collapsedProjectIds.filter((id) => id !== projectId) - : [...s.collapsedProjectIds, projectId], - })), - - isProjectCollapsed: (projectId) => - get().collapsedProjectIds.includes(projectId), - }), - { name: "workspace-sidebar-store" } - ) - ) - ); - -### New Shared Hook - -In `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts`: - - export function useWorkspaceShortcuts() { - // Extract from WorkspacesTabs: ⌘1-9 shortcuts + auto-create logic - // Used by BOTH WorkspaceSidebar and WorkspacesTabs - } - -### Component Props - - interface WorkspaceListItemProps { - workspace: { - id: string; - name: string; - branch: string; - worktreePath: string; - type: "worktree" | "branch"; - projectId: string; - }; - project: { - id: string; - name: string; - color: string; - }; - isActive: boolean; - index: number; // for ⌘N shortcut display - onSelect: () => void; - onHover: () => void; - } - ---- - -## Dependencies on External Data - -The following data is needed for full feature parity with the design: - -1. **PR Status + Diff Stats** - Available via `workspaces.getGitHubStatus` (already used by WorkspaceHoverCard) - - This is the authoritative source for PR additions/deletions - - Do NOT add a new git diff endpoint - -2. **Workspace Status** (uncommitted changes) - Available via changes router - - Fallback for workspaces without PRs - -3. **GitHub Author/Branch** - Extract from PR branch name or remote tracking branch - - Already available in workspace data - -**Performance strategy**: Lazy-load status on hover (matching WorkspaceHoverCard behavior). If batching is needed later, extend `getAllGrouped` to include summary status. From 8a5f264d793cfd52d890c89f43b11e6b9cc70d4b Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 14:56:22 +0200 Subject: [PATCH 46/64] fix(desktop): reduce visual intensity of workspace sidebar - Remove colored project dots (confusing overlap with working indicators) - Use muted text color for workspace items and header - Active workspace retains full foreground color with font-medium --- .../WorkspaceSidebar/ProjectSection/ProjectHeader.tsx | 6 ------ .../WorkspaceSidebar/ProjectSection/ProjectSection.tsx | 3 --- .../WorkspaceListItem/WorkspaceListItem.tsx | 7 ++++++- .../main/components/WorkspaceSidebar/WorkspaceSidebar.tsx | 1 - .../components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 85a1fe3d72a..02d333996ed 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -3,7 +3,6 @@ import { LuChevronDown, LuChevronRight } from "react-icons/lu"; interface ProjectHeaderProps { projectName: string; - projectColor: string; isCollapsed: boolean; onToggleCollapse: () => void; workspaceCount: number; @@ -11,7 +10,6 @@ interface ProjectHeaderProps { export function ProjectHeader({ projectName, - projectColor, isCollapsed, onToggleCollapse, workspaceCount, @@ -31,10 +29,6 @@ export function ProjectHeader({ ) : ( )} -
{projectName} {workspaceCount} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 75c933b2482..fe109d9971e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -28,7 +28,6 @@ interface Workspace { interface ProjectSectionProps { projectId: string; projectName: string; - projectColor: string; workspaces: Workspace[]; activeWorkspaceId: string | null; /** Base index for keyboard shortcuts (0-based) */ @@ -38,7 +37,6 @@ interface ProjectSectionProps { export function ProjectSection({ projectId, projectName, - projectColor, workspaces, activeWorkspaceId, shortcutBaseIndex, @@ -70,7 +68,6 @@ export function ProjectSection({
toggleProjectCollapsed(projectId)} workspaceCount={workspaces.length} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 04afd81d085..842ba2ea980 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -208,7 +208,12 @@ export function WorkspaceListItem({ ) : ( <>
- + {name} {pr && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index c4d68290bfb..d6bf908713f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -24,7 +24,6 @@ export function WorkspaceSidebar() { key={group.project.id} projectId={group.project.id} projectName={group.project.name} - projectColor={group.project.color} workspaces={group.workspaces} activeWorkspaceId={activeWorkspaceId} shortcutBaseIndex={projectShortcutIndices[index]} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx index bc54cd8e98b..2a6799c8bda 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx @@ -4,7 +4,7 @@ export function WorkspaceSidebarHeader() { return (
- Workspaces + Workspaces
); } From 54f7d18f489db79f01adc6a04b63d058224cefe6 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 15:08:41 +0200 Subject: [PATCH 47/64] fix: lint --- .../components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx index 2a6799c8bda..579b6db6039 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx @@ -4,7 +4,9 @@ export function WorkspaceSidebarHeader() { return (
- Workspaces + + Workspaces +
); } From 9f883e4873e584cacbf147978bd27412da10e8c3 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 18:10:13 +0200 Subject: [PATCH 48/64] fix(desktop): add unsaved changes dialog when switching file viewer modes - Add UnsavedChangesDialog with Cancel, Discard & Switch, Save & Switch options - Fix DiffViewer read-only mode not updating when editable prop changes - Use registerSaveAction with addAction for idempotent save command registration - Add isEditorMounted state to ensure Monaco effects run after mount - Track dirty state properly across Raw/Diff mode switches - Handle async save race conditions with proper await and loading state - Reset baseline refs when switching modes to prevent false dirty state --- .../components/DiffViewer/DiffViewer.tsx | 49 +++++- .../components/DiffViewer/editor-actions.ts | 10 +- .../TabView/FileViewerPane/FileViewerPane.tsx | 154 ++++++++++++++++-- .../FileViewerPane/UnsavedChangesDialog.tsx | 74 +++++++++ 4 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx index abcc51d516d..e13ba8d9984 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx @@ -1,6 +1,6 @@ import { DiffEditor, type DiffOnMount } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LuLoader } from "react-icons/lu"; import { SUPERSET_THEME, @@ -9,7 +9,7 @@ import { import type { DiffViewMode, FileContents } from "shared/changes-types"; import { registerCopyPathLineAction, - registerSaveCommand, + registerSaveAction, } from "./editor-actions"; interface DiffViewerProps { @@ -18,6 +18,7 @@ interface DiffViewerProps { filePath: string; editable?: boolean; onSave?: (content: string) => void; + onChange?: (content: string) => void; } export function DiffViewer({ @@ -26,17 +27,23 @@ export function DiffViewer({ filePath, editable = false, onSave, + onChange, }: DiffViewerProps) { const isMonacoReady = useMonacoReady(); const modifiedEditorRef = useRef( null, ); + // Track when editor is mounted to trigger effects at the right time + const [isEditorMounted, setIsEditorMounted] = useState(false); const handleSave = useCallback(() => { if (!editable || !onSave || !modifiedEditorRef.current) return; onSave(modifiedEditorRef.current.getValue()); }, [editable, onSave]); + // Store disposable for content change listener cleanup + const changeListenerRef = useRef(null); + const handleMount: DiffOnMount = useCallback( (editor) => { const originalEditor = editor.getOriginalEditor(); @@ -46,13 +53,43 @@ export function DiffViewer({ registerCopyPathLineAction(originalEditor, filePath); registerCopyPathLineAction(modifiedEditor, filePath); - if (editable) { - registerSaveCommand(modifiedEditor, handleSave); - } + setIsEditorMounted(true); }, - [editable, handleSave, filePath], + [filePath], ); + // Update readOnly and register save action when editable changes or editor mounts + // Using addAction with an ID allows replacing the action on subsequent calls + useEffect(() => { + if (!isEditorMounted || !modifiedEditorRef.current) return; + + modifiedEditorRef.current.updateOptions({ readOnly: !editable }); + + if (editable) { + registerSaveAction(modifiedEditorRef.current, handleSave); + } + }, [isEditorMounted, editable, handleSave]); + + // Set up content change listener for dirty tracking + useEffect(() => { + if (!isEditorMounted || !modifiedEditorRef.current || !onChange) return; + + // Clean up previous listener + changeListenerRef.current?.dispose(); + + changeListenerRef.current = + modifiedEditorRef.current.onDidChangeModelContent(() => { + if (modifiedEditorRef.current) { + onChange(modifiedEditorRef.current.getValue()); + } + }); + + return () => { + changeListenerRef.current?.dispose(); + changeListenerRef.current = null; + }; + }, [isEditorMounted, onChange]); + if (!isMonacoReady) { return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts index 7a5bb9c5933..90d6c4b7877 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts @@ -28,9 +28,15 @@ export function registerCopyPathLineAction( }); } -export function registerSaveCommand( +export function registerSaveAction( editor: Monaco.editor.IStandaloneCodeEditor, onSave: () => void, ) { - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, onSave); + // Using addAction with an ID allows replacing the action on subsequent calls + editor.addAction({ + id: "save-file", + label: "Save File", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: onSave, + }); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index e33ce675d2a..5ede82296e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -25,6 +25,7 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import type { Pane } from "renderer/stores/tabs/types"; import type { FileViewerMode } from "shared/tabs-types"; import { DiffViewer } from "../../../ChangesContent/components/DiffViewer"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; type SplitOrientation = "vertical" | "horizontal"; @@ -105,6 +106,14 @@ export function FileViewerPane({ const originalContentRef = useRef(""); // Store draft content to preserve edits across view mode switches const draftContentRef = useRef(null); + // Track original diff modified content for dirty comparison + const originalDiffContentRef = useRef(""); + // Track current diff content for save & switch + const currentDiffContentRef = useRef(""); + // Dialog state for unsaved changes prompt + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); + const [isSavingAndSwitching, setIsSavingAndSwitching] = useState(false); + const pendingModeRef = useRef(null); const utils = trpc.useUtils(); // Track container dimensions for auto-split orientation @@ -149,6 +158,8 @@ export function FileViewerPane({ // Track if we're saving from raw mode to know when to clear draft const savingFromRawRef = useRef(false); + // Track content being saved from diff mode for updating originalDiffContentRef + const savingDiffContentRef = useRef(null); // Track if we've applied initial line/column navigation (reset on file change) const hasAppliedInitialLocationRef = useRef(false); @@ -161,6 +172,11 @@ export function FileViewerPane({ if (editorRef.current) { originalContentRef.current = editorRef.current.getValue(); } + // Update diff baseline if we saved from Diff mode + if (savingDiffContentRef.current !== null) { + originalDiffContentRef.current = savingDiffContentRef.current; + savingDiffContentRef.current = null; + } // P1: Only clear draft if we saved from Raw mode (we saved the draft content) // Don't clear if saving from Diff mode as that would discard Raw edits if (savingFromRawRef.current) { @@ -194,25 +210,27 @@ export function FileViewerPane({ }, }); - // Save handler for raw mode editor - const handleSaveRaw = useCallback(() => { + // Save handler for raw mode editor - returns promise for async save & switch + const handleSaveRaw = useCallback(async () => { if (!editorRef.current || !filePath || !worktreePath) return; // Mark that we're saving from Raw mode so onSuccess knows to clear draft savingFromRawRef.current = true; - saveFileMutation.mutate({ + await saveFileMutation.mutateAsync({ worktreePath, filePath, content: editorRef.current.getValue(), }); }, [worktreePath, filePath, saveFileMutation]); - // Save handler for diff mode + // Save handler for diff mode - returns promise for async save & switch const handleSaveDiff = useCallback( - (content: string) => { + async (content: string) => { if (!filePath || !worktreePath) return; // Not saving from Raw mode - don't clear draft savingFromRawRef.current = false; - saveFileMutation.mutate({ + // Track content for updating diff baseline on success + savingDiffContentRef.current = content; + await saveFileMutation.mutateAsync({ worktreePath, filePath, content, @@ -248,9 +266,13 @@ export function FileViewerPane({ // Track content changes for dirty state const handleEditorChange = useCallback((value: string | undefined) => { - if (value !== undefined) { - setIsDirty(value !== originalContentRef.current); + if (value === undefined) return; + // If baseline is empty, this is initial load after a mode switch - set baseline + if (originalContentRef.current === "") { + originalContentRef.current = value; + return; } + setIsDirty(value !== originalContentRef.current); }, []); // Reset dirty state, draft, and initial location tracking when file changes @@ -309,6 +331,25 @@ export function FileViewerPane({ } }, [rawFileData]); + // Update originalDiffContentRef when diff data loads + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when diff loads + useEffect(() => { + if (diffData?.modified && !isDirty) { + originalDiffContentRef.current = diffData.modified; + } + }, [diffData]); + + // Handler for diff editor content changes + const handleDiffChange = useCallback((content: string) => { + currentDiffContentRef.current = content; + // If baseline is empty, this is initial mount after a mode switch - set baseline + if (originalDiffContentRef.current === "") { + originalDiffContentRef.current = content; + return; + } + setIsDirty(content !== originalDiffContentRef.current); + }, []); + // Apply initial line/column navigation when raw content is ready // NOTE: Line/column navigation only supported in raw mode. // Diff mode has different line numbers between sides; rendered mode has no line concept. @@ -391,16 +432,8 @@ export function FileViewerPane({ } }; - const handleViewModeChange = (value: string) => { - if (!value) return; - const newMode = value as FileViewerMode; - - // P1: Save current editor content before switching away from raw mode - if (viewMode === "raw" && editorRef.current) { - draftContentRef.current = editorRef.current.getValue(); - } - - // Update the pane's view mode in the store + // Helper to switch view mode + const switchToMode = (newMode: FileViewerMode) => { const panes = useTabsStore.getState().panes; const currentPane = panes[paneId]; if (currentPane?.fileViewer) { @@ -419,6 +452,83 @@ export function FileViewerPane({ } }; + const handleViewModeChange = (value: string) => { + if (!value) return; + const newMode = value as FileViewerMode; + + // If switching away from an editable mode with unsaved changes, show dialog + // This covers both Raw → Diff/Rendered and Diff → Raw/Rendered + if (isDirty && newMode !== viewMode) { + pendingModeRef.current = newMode; + setShowUnsavedDialog(true); + return; + } + + switchToMode(newMode); + }; + + const handleSaveAndSwitch = async () => { + if (!pendingModeRef.current) return; + + setIsSavingAndSwitching(true); + try { + // Save based on current view mode + // Note: use !== undefined to allow saving empty files (empty string is valid) + if (viewMode === "raw" && editorRef.current) { + const savedContent = editorRef.current.getValue(); + await handleSaveRaw(); + // Update baseline to saved content so dirty state resets + originalContentRef.current = savedContent; + // Reset diff baseline so useEffect sets fresh baseline when diff loads + originalDiffContentRef.current = ""; + } else if ( + viewMode === "diff" && + currentDiffContentRef.current !== undefined + ) { + const savedContent = currentDiffContentRef.current; + await handleSaveDiff(savedContent); + // Update baseline to saved content so dirty state resets + originalDiffContentRef.current = savedContent; + // Reset raw baseline so useEffect sets fresh baseline when raw loads + originalContentRef.current = ""; + } + + // Reset dirty state after successful save + setIsDirty(false); + draftContentRef.current = null; + currentDiffContentRef.current = ""; + + // Only switch after save succeeds + switchToMode(pendingModeRef.current); + pendingModeRef.current = null; + setShowUnsavedDialog(false); + } catch (error) { + // Save failed - stay in current mode, dialog stays open + console.error("[FileViewerPane] Save failed:", error); + } finally { + setIsSavingAndSwitching(false); + } + }; + + const handleDiscardAndSwitch = () => { + if (!pendingModeRef.current) return; + + // Reset based on current view mode + if (viewMode === "raw" && editorRef.current) { + editorRef.current.setValue(originalContentRef.current); + } + // For diff mode, we just need to reset the dirty state + // The diff viewer will reload from the file when we switch back + + setIsDirty(false); + draftContentRef.current = null; + currentDiffContentRef.current = ""; + + // Switch to the pending mode + switchToMode(pendingModeRef.current); + pendingModeRef.current = null; + }; + const fileName = filePath.split("/").pop() || filePath; // P1-3: Only allow editing for staged/unstaged diffs (not committed/against-main) @@ -457,6 +567,7 @@ export function FileViewerPane({ filePath={filePath} editable={isDiffEditable} onSave={isDiffEditable ? handleSaveDiff : undefined} + onChange={isDiffEditable ? handleDiffChange : undefined} /> ); } @@ -667,6 +778,13 @@ export function FileViewerPane({ > {renderContent()}
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx new file mode 100644 index 00000000000..98c47db4407 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx @@ -0,0 +1,74 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { LuLoader } from "react-icons/lu"; + +interface UnsavedChangesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaveAndSwitch: () => void; + onDiscardAndSwitch: () => void; + isSaving?: boolean; +} + +export function UnsavedChangesDialog({ + open, + onOpenChange, + onSaveAndSwitch, + onDiscardAndSwitch, + isSaving = false, +}: UnsavedChangesDialogProps) { + const handleSaveAndSwitch = (e: React.MouseEvent) => { + e.preventDefault(); + onSaveAndSwitch(); + // Don't close dialog - parent will close on success + }; + + const handleDiscardAndSwitch = (e: React.MouseEvent) => { + e.preventDefault(); + onDiscardAndSwitch(); + onOpenChange(false); + }; + + return ( + + + + Unsaved Changes + + You have unsaved changes. What would you like to do? + + + + Cancel + + + {isSaving ? ( + <> + + Saving... + + ) : ( + "Save & Switch" + )} + + + + + ); +} From 57146dd27f709a7373eaf896743a2bc6fd5c2448 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 19:47:19 +0200 Subject: [PATCH 49/64] feat(desktop): add configurable group tabs position setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new setting to control where terminal group tabs are displayed: - "Sidebar" (default): Groups shown in left sidebar via ModeCarousel + TabsView with rename, reorder, and preset features - "Content header": Groups shown as compact horizontal strip (GroupStrip) This restores the original sidebar behavior as the default while making the content header position available as an option. Implementation: - Add GroupTabsPosition type and column to local-db schema - Add getGroupTabsPosition/setGroupTabsPosition tRPC procedures - Update Sidebar to conditionally render ModeCarousel or ChangesView - Update ContentView to conditionally show GroupStrip - Add dropdown in Settings → Behavior Review mode always shows ChangesView regardless of setting. --- ...0260104-1916-restore-group-tabs-sidebar.md | 646 +++++++++++ .../src/lib/trpc/routers/settings/index.ts | 21 + .../SettingsView/BehaviorSettings.tsx | 56 + .../WorkspaceView/ContentView/index.tsx | 33 +- .../WorkspaceView/Sidebar/index.tsx | 48 +- apps/desktop/src/shared/constants.ts | 1 + .../drizzle/0008_add_group_tabs_position.sql | 1 + .../local-db/drizzle/meta/0008_snapshot.json | 999 ++++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 7 + packages/local-db/src/schema/schema.ts | 6 + 10 files changed, 1810 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/plans/20260104-1916-restore-group-tabs-sidebar.md create mode 100644 packages/local-db/drizzle/0008_add_group_tabs_position.sql create mode 100644 packages/local-db/drizzle/meta/0008_snapshot.json diff --git a/apps/desktop/plans/20260104-1916-restore-group-tabs-sidebar.md b/apps/desktop/plans/20260104-1916-restore-group-tabs-sidebar.md new file mode 100644 index 00000000000..7ee522ee136 --- /dev/null +++ b/apps/desktop/plans/20260104-1916-restore-group-tabs-sidebar.md @@ -0,0 +1,646 @@ +# Restore Group Tabs to Sidebar with Configurable Position + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from AGENTS.md and the ExecPlan template. + +## Purpose / Big Picture + +PR #586 accidentally moved tab groups (the "Group 1", "Group 2" tabs that organize terminals/panes within a workspace) from their original location in the left sidebar to a horizontal strip in the content header. This is a breaking change from the behavior on the `main` branch. + +After this change: +- **Default behavior** matches `main`: tab groups appear in the left sidebar via `ModeCarousel` + `TabsView` +- **New option**: users can choose to display groups in the content header (horizontal `GroupStrip`) via Settings +- The Workbench/Review mode toggle continues to work correctly with either setting + +To verify: Open Settings → Behavior → "Group tabs position" dropdown. Switching between "Sidebar" and "Content header" immediately changes where groups are displayed. + +## Assumptions + +1. The `ModeCarousel` and `TabsView` components still exist on the `main` branch and can be restored via git checkout +2. The existing `navigationStyle` setting pattern is the correct template to follow +3. Feature parity between `TabsView` (sidebar) and `GroupStrip` (header) is NOT required - they can have different capabilities (TabsView has rename/reorder/presets; GroupStrip is a compact switcher) +4. Review mode must always show the changes/files view regardless of group tabs position setting +5. The current Sidebar passes `onFileOpen` to `ChangesView` for FileViewerPane integration - this must be preserved + +## Open Questions + +None - all questions resolved during planning phase. + +## Progress + +- [x] Milestone 1: Database schema and tRPC procedures +- [x] Milestone 2: Restore sidebar components from main (if missing) — SKIPPED (components already existed) +- [x] Milestone 3: Conditional rendering logic +- [x] Milestone 4: Settings UI +- [x] Milestone 5: Validation and QA + +## Surprises & Discoveries + +- `TabsView` and `ModeCarousel` directories already existed in the branch, so Milestone 2 was skipped +- The `generate` script in local-db is just `generate`, not `db:generate` as originally documented in plan +- Biome auto-fixed one formatting issue in BehaviorSettings.tsx (long line wrapping) + +## Decision Log + +- Decision: Default to "sidebar" position + Rationale: Matches existing behavior on main branch, minimizes breaking change + Date/Author: 2026-01-04 / User + +- Decision: Feature parity NOT required between TabsView and GroupStrip + Rationale: They serve different UX needs; TabsView has rename/reorder/presets, GroupStrip is compact switcher. Settings UI should clarify this tradeoff. + Date/Author: 2026-01-04 / Planning + +- Decision: Review mode always shows ChangesView regardless of setting + Rationale: Review mode requires file list to function; showing tabs would break UX + Date/Author: 2026-01-04 / Oracle review + +- Decision: Use drizzle-kit generate for migration (not hand-create SQL) + Rationale: Desktop app migrator requires `drizzle/meta/_journal.json` to be updated; hand-created SQL won't run + Date/Author: 2026-01-04 / Oracle review + +## Outcomes & Retrospective + +**Completion Date**: 2026-01-04 + +**Summary**: Successfully implemented configurable group tabs position with default "sidebar" to match main branch behavior. The feature: +- Adds new `groupTabsPosition` setting with options: "sidebar" (default) or "content-header" +- Restores ModeCarousel + TabsView in sidebar when position is "sidebar" +- Shows GroupStrip in content header when position is "content-header" +- Properly handles Review mode (always shows ChangesView, never tabs) +- Includes Settings UI with description of feature differences between modes + +**Files Changed**: +- `packages/local-db/src/schema/schema.ts` - Added GroupTabsPosition type and column +- `packages/local-db/drizzle/0008_add_group_tabs_position.sql` - Migration (generated) +- `apps/desktop/src/shared/constants.ts` - Added DEFAULT_GROUP_TABS_POSITION +- `apps/desktop/src/lib/trpc/routers/settings/index.ts` - Added getter/setter procedures +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx` - Conditional ModeCarousel rendering +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` - Conditional GroupStrip rendering +- `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` - Settings dropdown + +**What Went Well**: +- Oracle review caught critical issues upfront (migration workflow, viewMode handling) +- Existing patterns (navigationStyle) provided clear template to follow +- Components already existed, reducing scope + +**What Could Be Improved**: +- Plan's script name was wrong (`db:generate` vs `generate`) - should verify commands before documenting + +## Context and Orientation + +### Apps and Packages Affected +- **App**: `apps/desktop` (Electron desktop application) +- **Packages**: `packages/local-db` (SQLite schema and migrations) + +### Key Concepts + +**ModeCarousel**: A swipeable carousel component in the left sidebar that switches between different views. On `main`, it has two modes: "Tabs" (showing tab groups) and "Changes" (showing git changes/files). + +**TabsView**: The component rendered when ModeCarousel is in "Tabs" mode. Shows a vertical list of tab groups with features like rename, drag-and-drop reorder, and terminal presets. + +**GroupStrip**: A new horizontal component added in PR #586 that shows tab groups in the content header area. More compact but fewer features than TabsView. + +**viewMode**: The Workbench/Review toggle state. "workbench" shows the mosaic panes layout; "review" shows the dedicated changes page. + +**onFileOpen**: A callback prop passed to `ChangesView` that opens files in `FileViewerPane` when in workbench mode. Must be preserved in all Sidebar rendering paths. + +### Current State (PR #586 branch) + +The sidebar (`apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx`) no longer uses `ModeCarousel`. It renders `ChangesView` with `onFileOpen` prop for workbench mode integration. + +The `ContentView` (`apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx`) renders `GroupStrip` in a `ContentHeader` wrapper. It passes `workspaceId` and `worktreePath` to `WorkspaceControls`. + +### Target State (after this plan) + +The sidebar conditionally renders based on BOTH `viewMode` AND `groupTabsPosition`: +1. **Review mode (any position)**: Always show `ChangesView` only +2. **Workbench + content-header**: Show `ChangesView` with `onFileOpen` +3. **Workbench + sidebar**: Show `ModeCarousel` with Tabs/Changes modes + +The `ContentView` conditionally renders: +1. **Review mode**: No `ContentHeader` with GroupStrip +2. **Workbench + content-header**: `ContentHeader` with `GroupStrip` +3. **Workbench + sidebar + top-bar nav**: No `ContentHeader` (avoid empty header) +4. **Workbench + sidebar + sidebar nav**: `ContentHeader` with controls only (no GroupStrip) + +### File Inventory + +Files to create: +- Migration file (generated by drizzle-kit, name TBD like `0008_add_group_tabs_position.sql`) + +Files to modify: +- `packages/local-db/src/schema/schema.ts` +- `apps/desktop/src/shared/constants.ts` +- `apps/desktop/src/lib/trpc/routers/settings/index.ts` +- `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` + +Files to restore from `main` (only if missing/different): +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/` (entire directory) +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/` (entire directory) + +## Plan of Work + +### Milestone 1: Database Schema and tRPC Procedures + +Add the new setting to the database schema and expose it via tRPC. + +**Step 1.1: Add type to schema** + +In `packages/local-db/src/schema/schema.ts`, add after the `NavigationStyle` type: + +```typescript +export type GroupTabsPosition = "sidebar" | "content-header"; +``` + +In the same file, add to the `settings` table definition: + +```typescript +groupTabsPosition: text("group_tabs_position").$type(), +``` + +**Step 1.2: Generate migration with drizzle-kit** + +IMPORTANT: Do NOT hand-create the migration file. The desktop app's migrator requires `drizzle/meta/_journal.json` to be updated. + +```bash +cd packages/local-db +bun run db:generate --name add_group_tabs_position +``` + +This will: +- Create `drizzle/XXXX_add_group_tabs_position.sql` with the ALTER TABLE statement +- Update `drizzle/meta/_journal.json` with the new migration entry +- Update `drizzle/meta/XXXX_snapshot.json` with the new schema snapshot + +Verify the generated SQL contains: +```sql +ALTER TABLE settings ADD COLUMN group_tabs_position TEXT; +``` + +**Step 1.3: Add default constant** + +In `apps/desktop/src/shared/constants.ts`, add: + +```typescript +export const DEFAULT_GROUP_TABS_POSITION = "sidebar" as const; +``` + +**Step 1.4: Add tRPC procedures** + +In `apps/desktop/src/lib/trpc/routers/settings/index.ts`: + +Add import at top: +```typescript +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; +``` + +Note: Use `shared/constants` NOT `@shared/constants` (the @ alias doesn't exist in this codebase). + +Add getter and setter following the `navigationStyle` pattern: + +```typescript +getGroupTabsPosition: publicProcedure.query(() => { + const row = getSettings(); + return row.groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; +}), + +setGroupTabsPosition: publicProcedure + .input(z.object({ position: z.enum(["sidebar", "content-header"]) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, groupTabsPosition: input.position }) + .onConflictDoUpdate({ + target: settings.id, + set: { groupTabsPosition: input.position }, + }) + .run(); + + return { success: true }; + }), +``` + +### Milestone 2: Restore Sidebar Components from Main (if needed) + +Check if `TabsView` and `ModeCarousel` exist in the current branch. If missing or different from main, restore them. + +**Step 2.1: Check if restoration is needed** + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +# Check if TabsView exists +ls apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/ 2>/dev/null || echo "TabsView MISSING - needs restore" + +# Check if ModeCarousel exists +ls apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ 2>/dev/null || echo "ModeCarousel MISSING - needs restore" +``` + +**Step 2.2: Restore TabsView directory (if missing)** + +```bash +git checkout main -- apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/ +``` + +This restores: +- `TabsView/index.tsx` - Main component +- `TabsView/TabItem.tsx` - Individual tab item +- `TabsView/PortsList.tsx` - Port forwarding list +- `TabsView/PresetContextMenu.tsx` - Terminal presets menu +- `TabsView/TabsCommandDialog.tsx` - Command palette for tabs + +**Step 2.3: Restore ModeCarousel directory (if missing)** + +```bash +git checkout main -- apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ +``` + +This restores: +- `ModeCarousel/index.tsx` - Main carousel component +- `ModeCarousel/ModeHeader.tsx` - Header for each mode +- `ModeCarousel/ModeContent.tsx` - Content wrapper +- `ModeCarousel/ModeNavigation.tsx` - Navigation dots +- `ModeCarousel/types.ts` - Type definitions +- `ModeCarousel/hooks/` - Custom hooks for carousel behavior + +**Step 2.4: Verify restoration and check for API drift** + +After restoration, run typecheck to catch any import/API mismatches: + +```bash +bun run typecheck --filter=@superset/desktop +``` + +If there are errors, they indicate API drift that must be resolved before proceeding. + +### Milestone 3: Conditional Rendering Logic + +Update the Sidebar and ContentView to conditionally render based on the setting AND viewMode. + +**Step 3.1: Update Sidebar** + +Read the current `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx` first to understand existing props and structure. + +The updated Sidebar must: +1. Check `viewMode` FIRST - Review mode always shows ChangesView only +2. Preserve `onFileOpen` prop passing to `ChangesView` +3. Conditionally show ModeCarousel vs ChangesView based on `groupTabsPosition` + +```typescript +import { trpc } from "renderer/lib/trpc"; +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; +import { SidebarMode, useSidebarStore } from "renderer/stores"; +import { useViewModeStore } from "renderer/stores/workspace-view-mode"; +import { ChangesView } from "./ChangesView"; +import { ModeCarousel } from "./ModeCarousel"; +import { TabsView } from "./TabsView"; + +interface SidebarProps { + onFileOpen?: (filePath: string, options?: { line?: number; column?: number }) => void; +} + +export function Sidebar({ onFileOpen }: SidebarProps) { + const { data: groupTabsPosition } = trpc.settings.getGroupTabsPosition.useQuery(); + const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + + const viewMode = useViewModeStore((s) => s.viewMode); + const { currentMode, setMode } = useSidebarStore(); + + // CRITICAL: Review mode ALWAYS shows ChangesView only, regardless of setting + // This ensures the file list is always available for review + if (viewMode === "review") { + return ( + + ); + } + + // Workbench mode with groups in content header: only show ChangesView + if (effectivePosition === "content-header") { + return ( + + ); + } + + // Workbench mode with groups in sidebar: show ModeCarousel with Tabs/Changes + const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + + return ( + + ); +} +``` + +**Step 3.2: Update ContentView** + +Read the current `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` first to understand existing structure and props. + +Key changes: +1. Add query for `groupTabsPosition` +2. Only show GroupStrip when `viewMode === "workbench"` AND `groupTabsPosition === "content-header"` +3. Only render ContentHeader when needed (avoid empty header in top-bar mode with sidebar groups) +4. Preserve existing props to WorkspaceControls (`workspaceId`, `worktreePath`) + +The logic for whether to show ContentHeader: + +```typescript +const { data: groupTabsPosition } = trpc.settings.getGroupTabsPosition.useQuery(); +const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + +// Show GroupStrip only in workbench mode with content-header position +const showGroupStrip = viewMode === "workbench" && effectivePosition === "content-header"; + +// Show ContentHeader if: +// 1. In sidebar navigation mode (needs SidebarControl and WorkspaceControls), OR +// 2. GroupStrip should be shown +const showContentHeader = isSidebarMode || showGroupStrip; +``` + +In the JSX: + +```typescript +{showContentHeader && ( + : undefined} + trailingAction={ + isSidebarMode ? ( + + ) : undefined + } + > + {showGroupStrip ? :
} + +)} +``` + +### Milestone 4: Settings UI + +Add the dropdown to the Behavior settings page. + +**Step 4.1: Update BehaviorSettings** + +In `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx`: + +Add import: +```typescript +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; +``` + +Add the query and mutation (following existing patterns): + +```typescript +const { data: groupTabsPosition, isLoading: isGroupTabsLoading } = + trpc.settings.getGroupTabsPosition.useQuery(); + +const setGroupTabsPositionMutation = trpc.settings.setGroupTabsPosition.useMutation({ + onMutate: async ({ position }) => { + await utils.settings.getGroupTabsPosition.cancel(); + const previous = utils.settings.getGroupTabsPosition.getData(); + utils.settings.getGroupTabsPosition.setData(undefined, position); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + utils.settings.getGroupTabsPosition.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getGroupTabsPosition.invalidate(); + }, +}); +``` + +Add the Select component in the JSX (after the Navigation style select): + +```tsx +
+
+ +

+ Where to display terminal group tabs. Sidebar includes rename, reorder, and presets; header is compact. +

+
+ +
+``` + +Note: Disable the select during BOTH loading AND mutation pending states. + +### Milestone 5: Validation and QA + +Run all validation commands and test the feature matrix. + +## Concrete Steps + +### After Milestone 1: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +# Verify migration was generated +ls packages/local-db/drizzle/*.sql | tail -1 +# Expected: Shows the new migration file + +# Check journal was updated +cat packages/local-db/drizzle/meta/_journal.json | tail -20 +# Expected: Shows entry for new migration + +# Typecheck local-db +bun run typecheck --filter=@superset/local-db +# Expected: No errors +``` + +### After Milestone 2: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +# Verify directories exist +ls -la apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/ +ls -la apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ +# Expected: Both directories exist with their files + +# Check for API drift +bun run typecheck --filter=@superset/desktop +# Expected: No errors (or document any that need fixing) +``` + +### After Milestone 4: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +bun run typecheck +# Expected: No type errors + +bun run lint:fix +# Expected: Fixes applied, no remaining errors +``` + +### Final Validation: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar +bun dev +# Expected: Desktop app opens +``` + +## Validation and Acceptance + +### QA Test Matrix + +Test all combinations: + +| navigationStyle | groupTabsPosition | viewMode | Expected Behavior | +|----------------|-------------------|----------|-------------------| +| top-bar | sidebar | workbench | Groups in left sidebar via ModeCarousel, no ContentHeader | +| top-bar | sidebar | review | ChangesView only (no ModeCarousel, no group tabs) | +| top-bar | content-header | workbench | GroupStrip in content header | +| top-bar | content-header | review | No GroupStrip, no ContentHeader | +| sidebar | sidebar | workbench | Groups in left sidebar via ModeCarousel, ContentHeader with controls | +| sidebar | sidebar | review | ChangesView only, ContentHeader with controls | +| sidebar | content-header | workbench | GroupStrip in content header, ContentHeader with controls | +| sidebar | content-header | review | No GroupStrip, ContentHeader with controls only | + +### Additional QA Checks + +**Upgrade existing DB:** +1. Start app with existing database (has settings but no group_tabs_position column) +2. Migration should run automatically +3. Setting should default to "sidebar" +4. No crash on startup + +**GroupStrip functional checks (when in content-header mode):** +1. Click group tab to switch +2. Click + to add new group +3. Click × to close group +4. Verify groups persist after restart + +**TabsView functional checks (when in sidebar mode):** +1. Double-click tab to rename +2. Drag tab to reorder +3. Right-click for context menu +4. Terminal presets work + +### Acceptance Criteria + +1. Settings → Behavior shows "Group tabs position" dropdown +2. Default is "Sidebar" (matching main branch behavior) +3. Changing setting immediately updates UI (no restart needed) +4. Setting persists after app restart +5. In Review mode, groups are NEVER shown (regardless of setting) +6. ModeCarousel swipe gesture works when groups are in sidebar +7. TabsView features work: rename tab, drag reorder, terminal presets +8. Existing databases upgrade without crash +9. `onFileOpen` continues to work in workbench mode (files open in FileViewerPane) + +## Idempotence and Recovery + +All steps are idempotent: +- Schema changes use `onConflictDoUpdate` pattern +- Git checkout commands can be re-run safely +- drizzle-kit generate can be re-run (will no-op if already done) + +If something goes wrong: +- **Database (dev)**: Delete `~/.superset-dev/local.db` to reset (loses local settings) +- **Database (prod)**: Delete `~/.superset/local.db` to reset +- **Git**: `git checkout -- ` to restore any file to branch state +- **Migration issues**: Check `packages/local-db/drizzle/meta/_journal.json` is updated + +## Interfaces and Dependencies + +### New tRPC Procedures + +```typescript +// In apps/desktop/src/lib/trpc/routers/settings/index.ts + +getGroupTabsPosition: publicProcedure.query(() => GroupTabsPosition) + +setGroupTabsPosition: publicProcedure + .input(z.object({ position: z.enum(["sidebar", "content-header"]) })) + .mutation(() => { success: boolean }) +``` + +### New Types + +```typescript +// In packages/local-db/src/schema/schema.ts +export type GroupTabsPosition = "sidebar" | "content-header"; +``` + +### New Constants + +```typescript +// In apps/desktop/src/shared/constants.ts +export const DEFAULT_GROUP_TABS_POSITION = "sidebar" as const; +``` + +### Existing Dependencies Used + +- `useViewModeStore` from `renderer/stores/workspace-view-mode` - for checking workbench vs review +- `useSidebarStore` from `renderer/stores/sidebar-state` - for ModeCarousel state +- `SidebarMode` enum from `renderer/stores` - Tabs and Changes modes + +## Artifacts and Notes + +### Oracle Review Findings (incorporated into plan) + +1. **Migration workflow**: Must use `bun run db:generate` not hand-create SQL - migrator requires `_journal.json` +2. **Review mode enforcement**: Both Sidebar AND ContentView must check viewMode - tabs never in review +3. **Preserve onFileOpen**: Sidebar passes this to ChangesView for FileViewerPane integration +4. **ContentHeader conditional**: Don't show empty header in top-bar mode with sidebar groups +5. **WorkspaceControls props**: Must pass `workspaceId` and `worktreePath` +6. **Import path**: Use `shared/constants` not `@shared/constants` +7. **Select disabled state**: Disable during loading AND mutation pending +8. **Dev database path**: `~/.superset-dev/local.db` not `~/.superset/local.db` + +--- + +## Revision History + +- 2026-01-04 19:16 - Initial plan created +- 2026-01-04 19:45 - Updated with Oracle review feedback: + - Fixed migration workflow to use drizzle-kit generate + - Added viewMode check to Sidebar (review mode handling) + - Preserved onFileOpen prop in Sidebar + - Fixed ContentView to conditionally show ContentHeader + - Fixed import path (shared/constants not @shared/constants) + - Added check before restoring components in Milestone 2 + - Fixed database path in recovery section + - Enhanced QA with upgrade and functional checks + - Added mutation pending check to Select disabled state diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index abc5cd8f903..9f3093d8e8e 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -6,6 +6,7 @@ import { import { localDb } from "main/lib/local-db"; import { DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_GROUP_TABS_POSITION, DEFAULT_NAVIGATION_STYLE, DEFAULT_TERMINAL_LINK_BEHAVIOR, } from "shared/constants"; @@ -228,5 +229,25 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getGroupTabsPosition: publicProcedure.query(() => { + const row = getSettings(); + return row.groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + }), + + setGroupTabsPosition: publicProcedure + .input(z.object({ position: z.enum(["sidebar", "content-header"]) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, groupTabsPosition: input.position }) + .onConflictDoUpdate({ + target: settings.id, + set: { groupTabsPosition: input.position }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx index bbcd9c9d80f..08edeae5b71 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -11,6 +11,7 @@ import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; type NavigationStyle = "top-bar" | "sidebar"; +type GroupTabsPosition = "sidebar" | "content-header"; export function BehaviorSettings() { const utils = trpc.useUtils(); @@ -55,6 +56,29 @@ export function BehaviorSettings() { }, }); + // Group tabs position setting + const { data: groupTabsPosition, isLoading: isGroupTabsLoading } = + trpc.settings.getGroupTabsPosition.useQuery(); + const setGroupTabsPosition = trpc.settings.setGroupTabsPosition.useMutation({ + onMutate: async ({ position }) => { + await utils.settings.getGroupTabsPosition.cancel(); + const previous = utils.settings.getGroupTabsPosition.getData(); + utils.settings.getGroupTabsPosition.setData(undefined, position); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getGroupTabsPosition.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getGroupTabsPosition.invalidate(); + }, + }); + const handleConfirmToggle = (enabled: boolean) => { setConfirmOnQuit.mutate({ enabled }); }; @@ -94,6 +118,10 @@ export function BehaviorSettings() { setNavigationStyle.mutate({ style }); }; + const handleGroupTabsPositionChange = (position: GroupTabsPosition) => { + setGroupTabsPosition.mutate({ position }); + }; + return (
@@ -129,6 +157,34 @@ export function BehaviorSettings() {
+ {/* Group Tabs Position */} +
+
+ +

+ Sidebar includes rename, reorder, and presets; header is compact +

+
+ +
+ {/* Confirm on Quit */}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 01d80ffa175..55cc4914f44 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,6 +1,9 @@ import { trpc } from "renderer/lib/trpc"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; -import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; +import { + DEFAULT_GROUP_TABS_POSITION, + DEFAULT_NAVIGATION_STYLE, +} from "shared/constants"; import { SidebarControl } from "../../SidebarControl"; import { WorkspaceControls } from "../../TopBar/WorkspaceControls"; import { ChangesContent } from "./ChangesContent"; @@ -26,6 +29,20 @@ export function ContentView() { const isSidebarMode = (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; + // Get group tabs position setting + const { data: groupTabsPosition } = + trpc.settings.getGroupTabsPosition.useQuery(); + const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + + // Show GroupStrip only in workbench mode with content-header position + const showGroupStrip = + viewMode === "workbench" && effectivePosition === "content-header"; + + // Show ContentHeader if: + // 1. In sidebar navigation mode (needs SidebarControl and WorkspaceControls), OR + // 2. GroupStrip should be shown + const showContentHeader = isSidebarMode || showGroupStrip; + // Render WorkspaceControls in ContentHeader when in sidebar mode const workspaceControls = isSidebarMode ? ( - : undefined} - trailingAction={workspaceControls} - > - - + {showContentHeader && ( + : undefined} + trailingAction={workspaceControls} + > + {showGroupStrip ? :
} + + )}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index b93ef1f3849..5ed5bc3f0ca 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -1,8 +1,12 @@ import { trpc } from "renderer/lib/trpc"; +import { SidebarMode, useSidebarStore } from "renderer/stores/sidebar-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; import { ChangesView } from "./ChangesView"; +import { ModeCarousel } from "./ModeCarousel"; +import { TabsView } from "./TabsView"; export function Sidebar() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); @@ -17,6 +21,14 @@ export function Sidebar() { ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") : "workbench"; + // Get group tabs position setting + const { data: groupTabsPosition } = + trpc.settings.getGroupTabsPosition.useQuery(); + const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + + // Sidebar mode carousel state + const { currentMode, setMode, isResizing } = useSidebarStore(); + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); // In Workbench mode, open files in FileViewerPane @@ -32,9 +44,43 @@ export function Sidebar() { } : undefined; + // CRITICAL: Review mode ALWAYS shows ChangesView only, regardless of setting + // This ensures the file list is always available for review + if (viewMode === "review") { + return ( + + ); + } + + // Workbench mode with groups in content header: only show ChangesView + if (effectivePosition === "content-header") { + return ( + + ); + } + + // Workbench mode with groups in sidebar: show ModeCarousel with Tabs/Changes + const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + return ( ); } diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 35c6207de41..786f1d9f2df 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -48,3 +48,4 @@ export const NOTIFICATION_EVENTS = { export const DEFAULT_CONFIRM_ON_QUIT = true; export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; +export const DEFAULT_GROUP_TABS_POSITION = "sidebar" as const; diff --git a/packages/local-db/drizzle/0008_add_group_tabs_position.sql b/packages/local-db/drizzle/0008_add_group_tabs_position.sql new file mode 100644 index 00000000000..7a61fa89fc4 --- /dev/null +++ b/packages/local-db/drizzle/0008_add_group_tabs_position.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `group_tabs_position` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0008_snapshot.json b/packages/local-db/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000000..3c3664f7ab7 --- /dev/null +++ b/packages/local-db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,999 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "160d23b9-a426-4e93-866a-3d6e6fc3c704", + "prevId": "a7b8c9d0-e1f2-3456-7890-abcdef123456", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "navigation_style": { + "name": "navigation_style", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_tabs_position": { + "name": "group_tabs_position", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index c63757dc471..3b06ca4a7b6 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1767350000000, "tag": "0007_add_workspace_is_unread", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1767548339097, + "tag": "0008_add_group_tabs_position", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 8c4daa138e6..1f0e3abe103 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -124,6 +124,11 @@ export type SelectWorkspace = typeof workspaces.$inferSelect; */ export type NavigationStyle = "top-bar" | "sidebar"; +/** + * Position for group tabs display + */ +export type GroupTabsPosition = "sidebar" | "content-header"; + /** * Settings table - single row with typed columns */ @@ -144,6 +149,7 @@ export const settings = sqliteTable("settings", { "terminal_link_behavior", ).$type(), navigationStyle: text("navigation_style").$type(), + groupTabsPosition: text("group_tabs_position").$type(), }); export type InsertSettings = typeof settings.$inferInsert; From 15b0ce7027f2619762796c6e9bdb8227a8941e6d Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Sun, 4 Jan 2026 21:03:22 +0200 Subject: [PATCH 50/64] fix(desktop): address oracle review feedback for group tabs position - Add runtime validation for stored groupTabsPosition (reset to default if invalid value found, following ringtone pattern) - Centralize valid positions as const to avoid drift between schema, tRPC input validation, and UI - Hoist SIDEBAR_MODES array to stable constant to avoid ModeCarousel effect churn on every render - Add trailing newline to migration SQL file --- .../src/lib/trpc/routers/settings/index.ts | 30 +++++++++++++++++-- .../WorkspaceView/Sidebar/index.tsx | 7 +++-- .../drizzle/0008_add_group_tabs_position.sql | 2 +- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 9f3093d8e8e..b78be4ebc21 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; const VALID_RINGTONE_IDS = RINGTONES.map((r) => r.id); +const VALID_GROUP_TABS_POSITIONS = ["sidebar", "content-header"] as const; function getSettings() { let row = localDb.select().from(settings).get(); @@ -232,11 +233,36 @@ export const createSettingsRouter = () => { getGroupTabsPosition: publicProcedure.query(() => { const row = getSettings(); - return row.groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + const storedPosition = row.groupTabsPosition; + + if (!storedPosition) { + return DEFAULT_GROUP_TABS_POSITION; + } + + if ( + VALID_GROUP_TABS_POSITIONS.includes( + storedPosition as (typeof VALID_GROUP_TABS_POSITIONS)[number], + ) + ) { + return storedPosition; + } + + console.warn( + `[settings] Invalid group tabs position "${storedPosition}" found, resetting to default`, + ); + localDb + .insert(settings) + .values({ id: 1, groupTabsPosition: DEFAULT_GROUP_TABS_POSITION }) + .onConflictDoUpdate({ + target: settings.id, + set: { groupTabsPosition: DEFAULT_GROUP_TABS_POSITION }, + }) + .run(); + return DEFAULT_GROUP_TABS_POSITION; }), setGroupTabsPosition: publicProcedure - .input(z.object({ position: z.enum(["sidebar", "content-header"]) })) + .input(z.object({ position: z.enum(VALID_GROUP_TABS_POSITIONS) })) .mutation(({ input }) => { localDb .insert(settings) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 5ed5bc3f0ca..021825b36b4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -8,6 +8,9 @@ import { ChangesView } from "./ChangesView"; import { ModeCarousel } from "./ModeCarousel"; import { TabsView } from "./TabsView"; +// Stable reference to avoid ModeCarousel effect churn +const SIDEBAR_MODES: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + export function Sidebar() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const workspaceId = activeWorkspace?.id; @@ -64,12 +67,10 @@ export function Sidebar() { } // Workbench mode with groups in sidebar: show ModeCarousel with Tabs/Changes - const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; - return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx index 579b6db6039..b51c2ab9292 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx @@ -2,7 +2,7 @@ import { LuLayers } from "react-icons/lu"; export function WorkspaceSidebarHeader() { return ( -
+
Workspaces diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx index 85abdc17a8d..496e9b7892d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -15,7 +15,7 @@ export function ContentHeader({ trailingAction, }: ContentHeaderProps) { return ( -
+
{leadingAction && (
{leadingAction}
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 55cc4914f44..91c1c870650 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -63,8 +63,8 @@ export function ContentView() {
)} -
-
+
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 84f22532079..0a367e9e3ac 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -128,7 +128,7 @@ export function WorkspaceView() {
-
+
diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 786f1d9f2df..9f7332ea468 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -47,5 +47,5 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; -export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; -export const DEFAULT_GROUP_TABS_POSITION = "sidebar" as const; +export const DEFAULT_NAVIGATION_STYLE = "sidebar" as const; +export const DEFAULT_GROUP_TABS_POSITION = "content-header" as const; From 92254a3048713d4dd76e97894ef5f38a94559656 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 4 Jan 2026 15:23:45 -0800 Subject: [PATCH 55/64] clean up hotkys system --- apps/desktop/package.json | 1 - .../renderer/hooks/useWorkspaceShortcuts.ts | 99 +++++++----- .../ContentHeader/ContentHeader.tsx | 2 +- .../TabsContent/Terminal/helpers.ts | 2 +- .../main/components/WorkspaceView/index.tsx | 153 +++++++++++------- .../src/renderer/screens/main/index.tsx | 13 +- bun.lock | 3 - 7 files changed, 167 insertions(+), 106 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 310f8c60c7a..f9f6089226b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -99,7 +99,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", - "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-mosaic-component": "^6.1.1", diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index d69aae5ad5e..14100c9ec03 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -1,11 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { useCreateBranchWorkspace, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; -import { getHotkey } from "shared/hotkeys"; +import { useAppHotkey } from "renderer/stores/hotkeys"; /** * Shared hook for workspace keyboard shortcuts and auto-creation logic. @@ -65,49 +64,73 @@ export function useWorkspaceShortcuts() { // Flatten workspaces for keyboard navigation const allWorkspaces = groups.flatMap((group) => group.workspaces); - // Workspace switching shortcuts (⌘/Ctrl+1-9) - // Using "mod" maps to Cmd on macOS and Ctrl on Windows/Linux - const workspaceKeys = Array.from( - { length: 9 }, - (_, i) => `mod+${i + 1}`, - ).join(", "); - - const handleWorkspaceSwitch = useCallback( - (event: KeyboardEvent) => { - const num = Number(event.key); - if (num >= 1 && num <= 9) { - const workspace = allWorkspaces[num - 1]; - if (workspace) { - setActiveWorkspace.mutate({ id: workspace.id }); - } + const switchToWorkspace = useCallback( + (index: number) => { + const workspace = allWorkspaces[index]; + if (workspace) { + setActiveWorkspace.mutate({ id: workspace.id }); } }, [allWorkspaces, setActiveWorkspace], ); - const handlePrevWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex > 0) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + useAppHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8), undefined, [ + switchToWorkspace, + ]); - const handleNextWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex < allWorkspaces.length - 1) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + useAppHotkey( + "PREV_WORKSPACE", + () => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex > 0) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); + } + }, + undefined, + [activeWorkspaceId, allWorkspaces, setActiveWorkspace], + ); - useHotkeys(workspaceKeys, handleWorkspaceSwitch); - useHotkeys(getHotkey("PREV_WORKSPACE"), handlePrevWorkspace); - useHotkeys(getHotkey("NEXT_WORKSPACE"), handleNextWorkspace); + useAppHotkey( + "NEXT_WORKSPACE", + () => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex < allWorkspaces.length - 1) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); + } + }, + undefined, + [activeWorkspaceId, allWorkspaces, setActiveWorkspace], + ); return { groups, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx index 496e9b7892d..398605b82a7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -15,7 +15,7 @@ export function ContentHeader({ trailingAction, }: ContentHeaderProps) { return ( -
+
{leadingAction && (
{leadingAction}
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index acc9eeb7a2e..fbd1b980b19 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -245,7 +245,7 @@ export function setupPasteHandler( /** * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook + * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) * - Clear terminal: Uses the configured clear shortcut * diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 0a367e9e3ac..a056aa9335d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,10 +1,9 @@ import { useMemo } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; +import { useAppHotkey } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { getNextPaneId, getPreviousPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; -import { getHotkey } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; @@ -52,77 +51,117 @@ export function WorkspaceView() { : "workbench"; // Tab management shortcuts - useHotkeys(getHotkey("NEW_GROUP"), () => { - if (activeWorkspaceId) { - // If in Review mode, switch to Workbench first - if (viewMode === "review") { - setWorkspaceViewMode(activeWorkspaceId, "workbench"); + useAppHotkey( + "NEW_GROUP", + () => { + if (activeWorkspaceId) { + // If in Review mode, switch to Workbench first + if (viewMode === "review") { + setWorkspaceViewMode(activeWorkspaceId, "workbench"); + } + addTab(activeWorkspaceId); } - addTab(activeWorkspaceId); - } - }, [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode]); + }, + undefined, + [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode], + ); - useHotkeys(getHotkey("CLOSE_TERMINAL"), () => { - // Close focused pane (which may close the tab if it's the last pane) - if (focusedPaneId) { - removePane(focusedPaneId); - } - }, [focusedPaneId, removePane]); + useAppHotkey( + "CLOSE_TERMINAL", + () => { + // Close focused pane (which may close the tab if it's the last pane) + if (focusedPaneId) { + removePane(focusedPaneId); + } + }, + undefined, + [focusedPaneId, removePane], + ); // Switch between tabs (⌘+Up/Down) - useHotkeys(getHotkey("PREV_TERMINAL"), () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index > 0) { - setActiveTab(activeWorkspaceId, tabs[index - 1].id); - } - }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + useAppHotkey( + "PREV_TERMINAL", + () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index > 0) { + setActiveTab(activeWorkspaceId, tabs[index - 1].id); + } + }, + undefined, + [activeWorkspaceId, activeTabId, tabs, setActiveTab], + ); - useHotkeys(getHotkey("NEXT_TERMINAL"), () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index < tabs.length - 1) { - setActiveTab(activeWorkspaceId, tabs[index + 1].id); - } - }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + useAppHotkey( + "NEXT_TERMINAL", + () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index < tabs.length - 1) { + setActiveTab(activeWorkspaceId, tabs[index + 1].id); + } + }, + undefined, + [activeWorkspaceId, activeTabId, tabs, setActiveTab], + ); // Switch between panes within a tab (⌘+⌥+Left/Right) - useHotkeys(getHotkey("PREV_PANE"), () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); - if (prevPaneId) { - setFocusedPane(activeTabId, prevPaneId); - } - }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); + useAppHotkey( + "PREV_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); + if (prevPaneId) { + setFocusedPane(activeTabId, prevPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); - useHotkeys(getHotkey("NEXT_PANE"), () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); - if (nextPaneId) { - setFocusedPane(activeTabId, nextPaneId); - } - }, [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane]); + useAppHotkey( + "NEXT_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); + if (nextPaneId) { + setFocusedPane(activeTabId, nextPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); // Open in last used app shortcut const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); const openInApp = trpc.external.openInApp.useMutation(); - useHotkeys("meta+o", () => { - if (activeWorkspace?.worktreePath) { - openInApp.mutate({ - path: activeWorkspace.worktreePath, - app: lastUsedApp, - }); - } - }, [activeWorkspace?.worktreePath, lastUsedApp]); + useAppHotkey( + "OPEN_IN_APP", + () => { + if (activeWorkspace?.worktreePath) { + openInApp.mutate({ + path: activeWorkspace.worktreePath, + app: lastUsedApp, + }); + } + }, + undefined, + [activeWorkspace?.worktreePath, lastUsedApp], + ); // Copy path shortcut const copyPath = trpc.external.copyPath.useMutation(); - useHotkeys("meta+shift+c", () => { - if (activeWorkspace?.worktreePath) { - copyPath.mutate(activeWorkspace.worktreePath); - } - }, [activeWorkspace?.worktreePath]); + useAppHotkey( + "COPY_PATH", + () => { + if (activeWorkspace?.worktreePath) { + copyPath.mutate(activeWorkspace.worktreePath); + } + }, + undefined, + [activeWorkspace?.worktreePath], + ); return (
diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 580f08bbf7f..3e1b5655349 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -3,7 +3,6 @@ import { Button } from "@superset/ui/button"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { useCallback, useState } from "react"; import { DndProvider } from "react-dnd"; -import { useHotkeys } from "react-hotkeys-hook"; import { HiArrowPath } from "react-icons/hi2"; import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { SetupConfigModal } from "renderer/components/SetupConfigModal"; @@ -22,7 +21,6 @@ import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener" import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; import { useWorkspaceSidebarStore } from "renderer/stores/workspace-sidebar-state"; import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; -import { getHotkey } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -122,9 +120,14 @@ export function MainScreen() { [toggleSidebar, isWorkspaceView], ); - useHotkeys(getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), () => { - if (isSidebarMode) toggleWorkspaceSidebar(); - }, [toggleWorkspaceSidebar, isSidebarMode]); + useAppHotkey( + "TOGGLE_WORKSPACE_SIDEBAR", + () => { + if (isSidebarMode) toggleWorkspaceSidebar(); + }, + undefined, + [toggleWorkspaceSidebar, isSidebarMode], + ); /** * Resolves the target pane for split operations. diff --git a/bun.lock b/bun.lock index 5e16802e3b1..cbb3e7cbcd7 100644 --- a/bun.lock +++ b/bun.lock @@ -190,7 +190,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", - "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-mosaic-component": "^6.1.1", @@ -3030,8 +3029,6 @@ "react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="], - "react-hotkeys-hook": ["react-hotkeys-hook@5.2.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg=="], - "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], From e69a45240e6ce6d351d9afbdce80fe94cee2aa6e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 4 Jan 2026 18:25:02 -0800 Subject: [PATCH 56/64] remove the ability to switch between settings --- .../src/lib/trpc/routers/settings/index.ts | 68 ----------- .../SettingsView/BehaviorSettings.tsx | 107 ------------------ .../screens/main/components/TopBar/index.tsx | 44 ++----- .../WorkspaceView/ContentView/index.tsx | 58 +++------- .../WorkspaceView/Sidebar/index.tsx | 43 +------ .../src/renderer/screens/main/index.tsx | 13 +-- apps/desktop/src/shared/constants.ts | 2 - packages/local-db/src/schema/schema.ts | 15 --- 8 files changed, 32 insertions(+), 318 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index b78be4ebc21..8c2e5ed6e83 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -6,8 +6,6 @@ import { import { localDb } from "main/lib/local-db"; import { DEFAULT_CONFIRM_ON_QUIT, - DEFAULT_GROUP_TABS_POSITION, - DEFAULT_NAVIGATION_STYLE, DEFAULT_TERMINAL_LINK_BEHAVIOR, } from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; @@ -15,7 +13,6 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; const VALID_RINGTONE_IDS = RINGTONES.map((r) => r.id); -const VALID_GROUP_TABS_POSITIONS = ["sidebar", "content-header"] as const; function getSettings() { let row = localDb.select().from(settings).get(); @@ -210,70 +207,5 @@ export const createSettingsRouter = () => { return { success: true }; }), - - getNavigationStyle: publicProcedure.query(() => { - const row = getSettings(); - return row.navigationStyle ?? DEFAULT_NAVIGATION_STYLE; - }), - - setNavigationStyle: publicProcedure - .input(z.object({ style: z.enum(["top-bar", "sidebar"]) })) - .mutation(({ input }) => { - localDb - .insert(settings) - .values({ id: 1, navigationStyle: input.style }) - .onConflictDoUpdate({ - target: settings.id, - set: { navigationStyle: input.style }, - }) - .run(); - - return { success: true }; - }), - - getGroupTabsPosition: publicProcedure.query(() => { - const row = getSettings(); - const storedPosition = row.groupTabsPosition; - - if (!storedPosition) { - return DEFAULT_GROUP_TABS_POSITION; - } - - if ( - VALID_GROUP_TABS_POSITIONS.includes( - storedPosition as (typeof VALID_GROUP_TABS_POSITIONS)[number], - ) - ) { - return storedPosition; - } - - console.warn( - `[settings] Invalid group tabs position "${storedPosition}" found, resetting to default`, - ); - localDb - .insert(settings) - .values({ id: 1, groupTabsPosition: DEFAULT_GROUP_TABS_POSITION }) - .onConflictDoUpdate({ - target: settings.id, - set: { groupTabsPosition: DEFAULT_GROUP_TABS_POSITION }, - }) - .run(); - return DEFAULT_GROUP_TABS_POSITION; - }), - - setGroupTabsPosition: publicProcedure - .input(z.object({ position: z.enum(VALID_GROUP_TABS_POSITIONS) })) - .mutation(({ input }) => { - localDb - .insert(settings) - .values({ id: 1, groupTabsPosition: input.position }) - .onConflictDoUpdate({ - target: settings.id, - set: { groupTabsPosition: input.position }, - }) - .run(); - - return { success: true }; - }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx index ca96531143b..7bbac096c0f 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -10,9 +10,6 @@ import { import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; -type NavigationStyle = "top-bar" | "sidebar"; -type GroupTabsPosition = "sidebar" | "content-header"; - export function BehaviorSettings() { const utils = trpc.useUtils(); @@ -36,49 +33,6 @@ export function BehaviorSettings() { }, }); - // Navigation style setting - const { data: navigationStyle, isLoading: isNavLoading } = - trpc.settings.getNavigationStyle.useQuery(); - const setNavigationStyle = trpc.settings.setNavigationStyle.useMutation({ - onMutate: async ({ style }) => { - await utils.settings.getNavigationStyle.cancel(); - const previous = utils.settings.getNavigationStyle.getData(); - utils.settings.getNavigationStyle.setData(undefined, style); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous !== undefined) { - utils.settings.getNavigationStyle.setData(undefined, context.previous); - } - }, - onSettled: () => { - utils.settings.getNavigationStyle.invalidate(); - }, - }); - - // Group tabs position setting - const { data: groupTabsPosition, isLoading: isGroupTabsLoading } = - trpc.settings.getGroupTabsPosition.useQuery(); - const setGroupTabsPosition = trpc.settings.setGroupTabsPosition.useMutation({ - onMutate: async ({ position }) => { - await utils.settings.getGroupTabsPosition.cancel(); - const previous = utils.settings.getGroupTabsPosition.getData(); - utils.settings.getGroupTabsPosition.setData(undefined, position); - return { previous }; - }, - onError: (_err, _vars, context) => { - if (context?.previous !== undefined) { - utils.settings.getGroupTabsPosition.setData( - undefined, - context.previous, - ); - } - }, - onSettled: () => { - utils.settings.getGroupTabsPosition.invalidate(); - }, - }); - const handleConfirmToggle = (enabled: boolean) => { setConfirmOnQuit.mutate({ enabled }); }; @@ -114,14 +68,6 @@ export function BehaviorSettings() { }); }; - const handleNavigationStyleChange = (style: NavigationStyle) => { - setNavigationStyle.mutate({ style }); - }; - - const handleGroupTabsPositionChange = (position: GroupTabsPosition) => { - setGroupTabsPosition.mutate({ position }); - }; - return (
@@ -132,59 +78,6 @@ export function BehaviorSettings() {
- {/* Navigation Style */} -
-
- -

- Choose how workspaces are displayed -

-
- -
- - {/* Group Tabs Position */} -
-
- -

- Sidebar includes rename, reorder, and presets; header is compact -

-
- -
- {/* Confirm on Quit */}
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index f9f163791dc..03e7f883889 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,22 +1,13 @@ -import type { NavigationStyle } from "@superset/local-db"; import { trpc } from "renderer/lib/trpc"; import { AvatarDropdown } from "../AvatarDropdown"; -import { SidebarControl } from "../SidebarControl"; import { WindowControls } from "./WindowControls"; -import { WorkspaceControls } from "./WorkspaceControls"; import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; -import { WorkspacesTabs } from "./WorkspaceTabs"; -interface TopBarProps { - navigationStyle?: NavigationStyle; -} - -export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { +export function TopBar() { const { data: platform } = trpc.window.getPlatform.useQuery(); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); // Default to Mac layout while loading to avoid overlap with traffic lights const isMac = platform === undefined || platform === "darwin"; - const isSidebarMode = navigationStyle === "sidebar"; return (
@@ -26,33 +17,20 @@ export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { paddingLeft: isMac ? "88px" : "16px", }} > - {isSidebarMode && } - {!isSidebarMode && } +
- {isSidebarMode ? ( -
- {activeWorkspace && ( - - {activeWorkspace.project?.name ?? "Workspace"} - / - {activeWorkspace.name} - - )} -
- ) : ( -
- -
- )} +
+ {activeWorkspace && ( + + {activeWorkspace.project?.name ?? "Workspace"} + / + {activeWorkspace.name} + + )} +
- {!isSidebarMode && ( - - )} {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 91c1c870650..bc008618d4f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,9 +1,5 @@ import { trpc } from "renderer/lib/trpc"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; -import { - DEFAULT_GROUP_TABS_POSITION, - DEFAULT_NAVIGATION_STYLE, -} from "shared/constants"; import { SidebarControl } from "../../SidebarControl"; import { WorkspaceControls } from "../../TopBar/WorkspaceControls"; import { ChangesContent } from "./ChangesContent"; @@ -24,45 +20,25 @@ export function ContentView() { ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") : "workbench"; - // Get navigation style to conditionally show sidebar toggle - const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); - const isSidebarMode = - (navigationStyle ?? DEFAULT_NAVIGATION_STYLE) === "sidebar"; + const showGroupStrip = viewMode === "workbench"; - // Get group tabs position setting - const { data: groupTabsPosition } = - trpc.settings.getGroupTabsPosition.useQuery(); - const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; - - // Show GroupStrip only in workbench mode with content-header position - const showGroupStrip = - viewMode === "workbench" && effectivePosition === "content-header"; - - // Show ContentHeader if: - // 1. In sidebar navigation mode (needs SidebarControl and WorkspaceControls), OR - // 2. GroupStrip should be shown - const showContentHeader = isSidebarMode || showGroupStrip; - - // Render WorkspaceControls in ContentHeader when in sidebar mode - const workspaceControls = isSidebarMode ? ( + const workspaceControls = ( - ) : undefined; + ); if (viewMode === "review") { return (
- {isSidebarMode && ( - } - trailingAction={workspaceControls} - > - {/* Review mode has no group tabs */} -
- - )} + } + trailingAction={workspaceControls} + > + {/* Review mode has no group tabs */} +
+
@@ -74,14 +50,12 @@ export function ContentView() { return (
- {showContentHeader && ( - : undefined} - trailingAction={workspaceControls} - > - {showGroupStrip ? :
} - - )} + } + trailingAction={workspaceControls} + > + {showGroupStrip ? :
} +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 021825b36b4..a7baf0f7122 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -1,21 +1,13 @@ import { trpc } from "renderer/lib/trpc"; -import { SidebarMode, useSidebarStore } from "renderer/stores/sidebar-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; -import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; import { ChangesView } from "./ChangesView"; -import { ModeCarousel } from "./ModeCarousel"; -import { TabsView } from "./TabsView"; - -// Stable reference to avoid ModeCarousel effect churn -const SIDEBAR_MODES: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; export function Sidebar() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const workspaceId = activeWorkspace?.id; - // Subscribe to the actual data, not just the getter function const viewModeByWorkspaceId = useWorkspaceViewModeStore( (s) => s.viewModeByWorkspaceId, ); @@ -24,17 +16,8 @@ export function Sidebar() { ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") : "workbench"; - // Get group tabs position setting - const { data: groupTabsPosition } = - trpc.settings.getGroupTabsPosition.useQuery(); - const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; - - // Sidebar mode carousel state - const { currentMode, setMode, isResizing } = useSidebarStore(); - const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); - // In Workbench mode, open files in FileViewerPane const handleFileOpen = viewMode === "workbench" && workspaceId ? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { @@ -47,8 +30,6 @@ export function Sidebar() { } : undefined; - // CRITICAL: Review mode ALWAYS shows ChangesView only, regardless of setting - // This ensures the file list is always available for review if (viewMode === "review") { return (
+ +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx index b269533cc7f..70b941149e3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -6,7 +6,7 @@ import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; -import { PortsList } from "../TabsView/PortsList"; + import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; @@ -360,8 +360,6 @@ export function ChangesView({ onFileOpen }: ChangesViewProps) {
)} - -
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx index 2baa293058b..06841519dc3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -17,7 +17,7 @@ import { trpc } from "renderer/lib/trpc"; import { usePresets } from "renderer/react-query/presets"; import { useOpenSettings, useSidebarStore } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; -import { PortsList } from "./PortsList"; + import { PresetContextMenu } from "./PresetContextMenu"; import { TabItem } from "./TabItem"; import { TabsCommandDialog } from "./TabsCommandDialog"; @@ -277,7 +277,6 @@ export function TabsView() {
)}
- ); From e5c9a0464210e83a2fcc3868324e91d099a55ee9 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 5 Jan 2026 09:51:27 +0200 Subject: [PATCH 58/64] remove dead WorkspaceTabs code and co-locate utilities - Delete ~2,460 lines of dead code from TopBar/WorkspaceTabs/ - Move useWorkspaceRename to shared hooks (used by 2 components) - Move BranchSwitcher, DeleteWorkspaceDialog, WorkspaceHoverCard into WorkspaceListItem/components/ (single consumer) - Fix outdated comment referencing top-bar mode --- .../renderer/hooks/useWorkspaceShortcuts.ts | 4 +- .../WorkspaceSettings/WorkspaceSettings.tsx | 2 +- .../WorkspaceTabs/CreateWorkspaceButton.tsx | 240 ---------------- .../TopBar/WorkspaceTabs/SettingsTab.tsx | 57 ---- .../TopBar/WorkspaceTabs/WorkspaceGroup.tsx | 95 ------- .../WorkspaceGroupContextMenu.tsx | 162 ----------- .../WorkspaceTabs/WorkspaceGroupHeader.tsx | 95 ------- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 259 ------------------ .../WorkspaceItemContextMenu.tsx | 125 --------- .../TopBar/WorkspaceTabs/constants.ts | 9 - .../components/TopBar/WorkspaceTabs/index.tsx | 130 --------- .../WorkspaceListItem/WorkspaceListItem.tsx | 10 +- .../BranchSwitcher/BranchSwitcher.tsx | 0 .../components}/BranchSwitcher/index.ts | 0 .../DeleteWorkspaceDialog.tsx | 0 .../components/DeleteWorkspaceDialog/index.ts | 1 + .../WorkspaceHoverCard/WorkspaceHoverCard.tsx | 0 .../components/ChecksList/ChecksList.tsx | 0 .../components/CheckItemRow/CheckItemRow.tsx | 0 .../components/CheckItemRow/index.ts | 0 .../components/ChecksList/index.ts | 0 .../ChecksSummary/ChecksSummary.tsx | 0 .../components/ChecksSummary/index.ts | 0 .../PRStatusBadge/PRStatusBadge.tsx | 0 .../components/PRStatusBadge/index.ts | 0 .../components/ReviewStatus/ReviewStatus.tsx | 0 .../components/ReviewStatus/index.ts | 0 .../components}/WorkspaceHoverCard/index.ts | 0 .../WorkspaceListItem/components/index.ts | 3 + .../src/renderer/screens/main/hooks/index.ts | 2 +- .../main/hooks/useWorkspaceRename/index.ts | 1 + .../useWorkspaceRename}/useWorkspaceRename.ts | 0 32 files changed, 14 insertions(+), 1181 deletions(-) delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/constants.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/BranchSwitcher/BranchSwitcher.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/BranchSwitcher/index.ts (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog}/DeleteWorkspaceDialog.tsx (100%) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/WorkspaceHoverCard.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ChecksList/index.ts (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ChecksSummary/index.ts (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/PRStatusBadge/index.ts (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/components/ReviewStatus/index.ts (100%) rename apps/desktop/src/renderer/screens/main/components/{TopBar/WorkspaceTabs => WorkspaceSidebar/WorkspaceListItem/components}/WorkspaceHoverCard/index.ts (100%) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts rename apps/desktop/src/renderer/screens/main/{components/TopBar/WorkspaceTabs => hooks/useWorkspaceRename}/useWorkspaceRename.ts (100%) diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index 14100c9ec03..acd914cb1e4 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -8,9 +8,7 @@ import { useAppHotkey } from "renderer/stores/hotkeys"; /** * Shared hook for workspace keyboard shortcuts and auto-creation logic. - * This hook should be used in both: - * - WorkspacesTabs (top-bar mode) - * - WorkspaceSidebar (sidebar mode) + * Used by WorkspaceSidebar for navigation between workspaces. * * It handles: * - ⌘1-9 workspace switching shortcuts diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx index 1a3a7ac954d..74285a1a968 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx @@ -2,7 +2,7 @@ import { Input } from "@superset/ui/input"; import { HiOutlineFolder, HiOutlinePencilSquare } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; export function WorkspaceSettings() { const { data: activeWorkspace, isLoading } = diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx deleted file mode 100644 index b63425907a5..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useCallback, useState } from "react"; -import { - HiChevronDown, - HiFolderOpen, - HiMiniPlus, - HiOutlineBolt, -} from "react-icons/hi2"; -import { trpc } from "renderer/lib/trpc"; -import { useOpenNew } from "renderer/react-query/projects"; -import { - useCreateBranchWorkspace, - useCreateWorkspace, -} from "renderer/react-query/workspaces"; -import { useAppHotkey, useHotkeyText } from "renderer/stores/hotkeys"; -import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; -import { InitGitDialog } from "../../StartView/InitGitDialog"; - -export interface CreateWorkspaceButtonProps { - className?: string; -} - -export function CreateWorkspaceButton({ - className, -}: CreateWorkspaceButtonProps) { - const [open, setOpen] = useState(false); - const [initGitDialog, setInitGitDialog] = useState<{ - isOpen: boolean; - selectedPath: string; - }>({ isOpen: false, selectedPath: "" }); - - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); - const createWorkspace = useCreateWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); - const openNew = useOpenNew(); - const openModal = useOpenNewWorkspaceModal(); - - const currentProject = recentProjects.find( - (p) => p.id === activeWorkspace?.projectId, - ); - - const isLoading = - createWorkspace.isPending || - createBranchWorkspace.isPending || - openNew.isPending; - - const handleModalCreate = useCallback(() => { - setOpen(false); - openModal(); - }, [openModal]); - - const handleOpenNewProject = useCallback(async () => { - setOpen(false); - try { - const result = await openNew.mutateAsync(undefined); - if (result.canceled) { - return; - } - if ("error" in result) { - toast.error("Failed to open project", { - description: result.error, - }); - return; - } - if ("needsGitInit" in result) { - setInitGitDialog({ - isOpen: true, - selectedPath: result.selectedPath, - }); - return; - } - // Create a main workspace on the current branch for the new project - toast.promise( - createBranchWorkspace.mutateAsync({ projectId: result.project.id }), - { - loading: "Opening project...", - success: "Project opened", - error: (err) => - err instanceof Error ? err.message : "Failed to open project", - }, - ); - } catch (error) { - toast.error("Failed to open project", { - description: - error instanceof Error ? error.message : "An unknown error occurred", - }); - } - }, [openNew, createBranchWorkspace]); - - const handleQuickCreate = useCallback(() => { - setOpen(false); - if (currentProject) { - toast.promise( - createWorkspace.mutateAsync({ projectId: currentProject.id }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - } else { - handleOpenNewProject(); - } - }, [currentProject, createWorkspace, handleOpenNewProject]); - - // Keyboard shortcuts - const handleQuickCreateHotkey = useCallback(() => { - if (!isLoading) handleQuickCreate(); - }, [isLoading, handleQuickCreate]); - - const handleOpenProjectHotkey = useCallback(() => { - if (!isLoading) handleOpenNewProject(); - }, [isLoading, handleOpenNewProject]); - - useAppHotkey("NEW_WORKSPACE", handleModalCreate, undefined, [ - handleModalCreate, - ]); - useAppHotkey("QUICK_CREATE_WORKSPACE", handleQuickCreateHotkey, undefined, [ - handleQuickCreateHotkey, - ]); - useAppHotkey("OPEN_PROJECT", handleOpenProjectHotkey, undefined, [ - handleOpenProjectHotkey, - ]); - - const newWorkspaceShortcut = useHotkeyText("NEW_WORKSPACE"); - const quickCreateShortcut = useHotkeyText("QUICK_CREATE_WORKSPACE"); - const openProjectShortcut = useHotkeyText("OPEN_PROJECT"); - const showNewWorkspaceShortcut = newWorkspaceShortcut !== "Unassigned"; - const showQuickCreateShortcut = quickCreateShortcut !== "Unassigned"; - const showOpenProjectShortcut = openProjectShortcut !== "Unassigned"; - - return ( -
- - - - - - New workspace - - - - - - - - - - - - More options - - - - - - New Workspace - {showNewWorkspaceShortcut && ( - - {newWorkspaceShortcut} - - )} - - - - Quick Create - {showQuickCreateShortcut && ( - - {quickCreateShortcut} - - )} - - - - - Open Project - {showOpenProjectShortcut && ( - - {openProjectShortcut} - - )} - - - - - setInitGitDialog({ isOpen: false, selectedPath: "" })} - onError={(error) => - toast.error("Failed to initialize git", { description: error }) - } - /> -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx deleted file mode 100644 index 1b567dddd6e..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { cn } from "@superset/ui/utils"; -import { HiMiniXMark, HiOutlineCog6Tooth } from "react-icons/hi2"; -import { - useCloseSettingsTab, - useOpenSettings, -} from "renderer/stores/app-state"; - -interface SettingsTabProps { - width: number; - isActive: boolean; -} - -export function SettingsTab({ width, isActive }: SettingsTabProps) { - const openSettings = useOpenSettings(); - const closeSettingsTab = useCloseSettingsTab(); - - return ( -
- - - -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx deleted file mode 100644 index 75505bf3313..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { useState } from "react"; -import { WorkspaceGroupHeader } from "./WorkspaceGroupHeader"; -import { WorkspaceItem } from "./WorkspaceItem"; - -interface Workspace { - id: string; - projectId: string; - worktreePath: string; - type: "worktree" | "branch"; - branch: string; - name: string; - tabOrder: number; - isUnread: boolean; -} - -interface WorkspaceGroupProps { - projectId: string; - projectName: string; - projectColor: string; - projectIndex: number; - workspaces: Workspace[]; - activeWorkspaceId: string | null; - workspaceWidth: number; - hoveredWorkspaceId: string | null; - onWorkspaceHover: (id: string | null) => void; -} - -export function WorkspaceGroup({ - projectId, - projectName, - projectColor, - projectIndex, - workspaces, - activeWorkspaceId, - workspaceWidth, - hoveredWorkspaceId: _hoveredWorkspaceId, - onWorkspaceHover, -}: WorkspaceGroupProps) { - const [isCollapsed, setIsCollapsed] = useState(false); - - return ( -
- {/* Project group badge */} - setIsCollapsed(!isCollapsed)} - /> - - {/* Workspaces with colored line (collapsed shows only active tab) */} -
- - {(isCollapsed - ? workspaces.filter((w) => w.id === activeWorkspaceId) - : workspaces - ).map((workspace, index) => ( - - onWorkspaceHover(workspace.id)} - onMouseLeave={() => onWorkspaceHover(null)} - /> - - ))} - -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx deleted file mode 100644 index 15f87c6e92c..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import type { KeyboardEvent, ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; -import { - useCloseProject, - useUpdateProject, -} from "renderer/react-query/projects"; -import { PROJECT_COLORS } from "shared/constants/project-colors"; - -interface WorkspaceGroupContextMenuProps { - projectId: string; - projectName: string; - projectColor: string; - children: ReactNode; -} - -export function WorkspaceGroupContextMenu({ - projectId, - projectName, - projectColor, - children, -}: WorkspaceGroupContextMenuProps) { - const [name, setName] = useState(projectName); - const inputRef = useRef(null); - const skipBlurSubmit = useRef(false); - const updateProject = useUpdateProject(); - const closeProject = useCloseProject(); - - useEffect(() => { - setName(projectName); - }, [projectName]); - - const handleOpenChange = (open: boolean) => { - if (open) { - // Small delay to ensure the menu is fully rendered - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - } - }; - - const submitName = () => { - const trimmed = name.trim(); - - if (!trimmed) { - setName(projectName); - return; - } - - if (trimmed !== name) { - setName(trimmed); - } - - if (trimmed !== projectName) { - updateProject.mutate({ - id: projectId, - patch: { name: trimmed }, - }); - } - }; - - const handleNameKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - skipBlurSubmit.current = true; - submitName(); - inputRef.current?.blur(); - } else if (event.key === "Escape") { - event.preventDefault(); - setName(projectName); - skipBlurSubmit.current = true; - inputRef.current?.blur(); - } - }; - - const handleBlur = () => { - if (skipBlurSubmit.current) { - skipBlurSubmit.current = false; - return; - } - - submitName(); - }; - - const handleColorChange = (color: string) => { - if (color === projectColor) { - return; - } - - updateProject.mutate({ - id: projectId, - patch: { color }, - }); - }; - - return ( - - {children} - -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > -

Workspace group name

- setName(event.target.value)} - onBlur={handleBlur} - onKeyDown={handleNameKeyDown} - className="w-full rounded-md border border-border bg-muted/50 px-2 py-1 text-sm text-foreground outline-none focus:border-primary focus:bg-background" - placeholder="Workspace group" - /> -
- - - -
- {PROJECT_COLORS.map((color) => ( - - ))} -
- - - - -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx deleted file mode 100644 index f7bfbfbe8c3..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useDrag, useDrop } from "react-dnd"; -import { useReorderProjects } from "renderer/react-query/projects"; -import { WorkspaceGroupContextMenu } from "./WorkspaceGroupContextMenu"; - -const PROJECT_GROUP_TYPE = "PROJECT_GROUP"; - -interface WorkspaceGroupHeaderProps { - projectId: string; - projectName: string; - projectColor: string; - isCollapsed: boolean; - index: number; - onToggleCollapse: () => void; -} - -export function WorkspaceGroupHeader({ - projectId, - projectName, - projectColor, - isCollapsed, - index, - onToggleCollapse, -}: WorkspaceGroupHeaderProps) { - const reorderProjects = useReorderProjects(); - - const [{ isDragging }, drag] = useDrag( - () => ({ - type: PROJECT_GROUP_TYPE, - item: { projectId, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [projectId, index], - ); - - const [{ isOver }, drop] = useDrop( - () => ({ - accept: PROJECT_GROUP_TYPE, - hover: (item: { projectId: string; index: number }) => { - if (item.index !== index) { - reorderProjects.mutate({ - fromIndex: item.index, - toIndex: index, - }); - item.index = index; - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }), - [index, reorderProjects], - ); - - return ( - -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx deleted file mode 100644 index 8b3dd67e6b2..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { useDrag, useDrop } from "react-dnd"; -import { HiMiniXMark } from "react-icons/hi2"; -import { LuGitBranch } from "react-icons/lu"; -import { - useReorderWorkspaces, - useSetActiveWorkspace, - useWorkspaceDeleteHandler, -} from "renderer/react-query/workspaces"; -import { useCloseSettings } from "renderer/stores/app-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; -import { BranchSwitcher } from "./BranchSwitcher"; -import { DELETE_TOOLTIP_DELAY, WORKSPACE_TOOLTIP_DELAY } from "./constants"; -import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; -import { useWorkspaceRename } from "./useWorkspaceRename"; -import { WorkspaceItemContextMenu } from "./WorkspaceItemContextMenu"; - -const WORKSPACE_TYPE = "WORKSPACE"; - -interface WorkspaceItemProps { - id: string; - projectId: string; - worktreePath: string; - workspaceType?: "worktree" | "branch"; - branch?: string; - title: string; - isActive: boolean; - isUnread?: boolean; - index: number; - width: number; - onMouseEnter?: () => void; - onMouseLeave?: () => void; -} - -export function WorkspaceItem({ - id, - projectId, - worktreePath, - workspaceType = "worktree", - branch, - title, - isActive, - isUnread = false, - index, - width, - onMouseEnter, - onMouseLeave, -}: WorkspaceItemProps) { - const isBranchWorkspace = workspaceType === "branch"; - const setActive = useSetActiveWorkspace(); - const reorderWorkspaces = useReorderWorkspaces(); - const closeSettings = useCloseSettings(); - const tabs = useTabsStore((s) => s.tabs); - const panes = useTabsStore((s) => s.panes); - const clearWorkspaceAttention = useTabsStore( - (s) => s.clearWorkspaceAttention, - ); - const rename = useWorkspaceRename(id, title); - - // Shared delete logic - const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = - useWorkspaceDeleteHandler(); - - // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) - const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - const workspacePaneIds = new Set( - workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), - ); - const hasPaneAttention = Object.values(panes) - .filter((p) => p != null && workspacePaneIds.has(p.id)) - .some((p) => p.needsAttention); - - // Show indicator if workspace is manually marked as unread OR has pane-level attention - const needsAttention = isUnread || hasPaneAttention; - - const [{ isDragging }, drag] = useDrag( - () => ({ - type: WORKSPACE_TYPE, - item: { id, projectId, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [id, projectId, index], - ); - - const [, drop] = useDrop({ - accept: WORKSPACE_TYPE, - hover: (item: { id: string; projectId: string; index: number }) => { - // Only allow reordering within the same project - if (item.projectId === projectId && item.index !== index) { - reorderWorkspaces.mutate({ - projectId, - fromIndex: item.index, - toIndex: index, - }); - item.index = index; - } - }, - }); - - return ( - <> - -
- {/* Main workspace button */} - - - {/* Only show close button for worktree workspaces */} - {!isBranchWorkspace && ( - - - - - - Close or delete - - - )} -
-
- - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx deleted file mode 100644 index fbba24e3362..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@superset/ui/hover-card"; -import type { ReactNode } from "react"; -import { LuEye, LuEyeOff } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; -import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; - -interface WorkspaceItemContextMenuProps { - children: ReactNode; - workspaceId: string; - worktreePath: string; - workspaceAlias?: string; - isUnread?: boolean; - onRename: () => void; - canRename?: boolean; - showHoverCard?: boolean; -} - -export function WorkspaceItemContextMenu({ - children, - workspaceId, - worktreePath, - workspaceAlias, - isUnread = false, - onRename, - canRename = true, - showHoverCard = true, -}: WorkspaceItemContextMenuProps) { - const utils = trpc.useUtils(); - const openInFinder = trpc.external.openInFinder.useMutation(); - const setUnread = trpc.workspaces.setUnread.useMutation({ - onSuccess: () => { - // Invalidate both queries that return isUnread state - utils.workspaces.getAllGrouped.invalidate(); - utils.workspaces.getActive.invalidate(); - }, - }); - - const handleOpenInFinder = () => { - if (worktreePath) { - openInFinder.mutate(worktreePath); - } - }; - - const handleToggleUnread = () => { - setUnread.mutate({ id: workspaceId, isUnread: !isUnread }); - }; - - const unreadMenuItem = ( - - {isUnread ? ( - <> - - Mark as Read - - ) : ( - <> - - Mark as Unread - - )} - - ); - - // For branch workspaces, just show context menu without hover card - if (!showHoverCard) { - return ( - - {children} - - {canRename && ( - <> - Rename - - - )} - - Open in Finder - - - {unreadMenuItem} - - - ); - } - - return ( - - - - {children} - - - {canRename && ( - <> - Rename - - - )} - - Open in Finder - - - {unreadMenuItem} - - - - - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/constants.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/constants.ts deleted file mode 100644 index d6abc8abb81..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Constants for workspace tabs behavior - */ - -/** Tooltip delay for delete button (ms) */ -export const DELETE_TOOLTIP_DELAY = 500; - -/** Tooltip delay for workspace name (ms) */ -export const WORKSPACE_TOOLTIP_DELAY = 600; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx deleted file mode 100644 index 8c8455c85ad..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { Fragment, useEffect, useRef, useState } from "react"; -import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; -import { - useCurrentView, - useIsSettingsTabOpen, -} from "renderer/stores/app-state"; -import { CreateWorkspaceButton } from "./CreateWorkspaceButton"; -import { SettingsTab } from "./SettingsTab"; -import { WorkspaceGroup } from "./WorkspaceGroup"; - -const MIN_WORKSPACE_WIDTH = 60; -const MAX_WORKSPACE_WIDTH = 160; -const ADD_BUTTON_WIDTH = 40; - -export function WorkspacesTabs() { - // Use shared hook for workspace shortcuts and auto-create logic - const { groups, allWorkspaces, activeWorkspaceId } = useWorkspaceShortcuts(); - - const currentView = useCurrentView(); - const isSettingsTabOpen = useIsSettingsTabOpen(); - const isSettingsActive = currentView === "settings"; - const containerRef = useRef(null); - const scrollRef = useRef(null); - const [showStartFade, setShowStartFade] = useState(false); - const [showEndFade, setShowEndFade] = useState(false); - const [workspaceWidth, setWorkspaceWidth] = useState(MAX_WORKSPACE_WIDTH); - const [hoveredWorkspaceId, setHoveredWorkspaceId] = useState( - null, - ); - - useEffect(() => { - const checkScroll = () => { - if (!scrollRef.current) return; - - const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setShowStartFade(scrollLeft > 0); - setShowEndFade(scrollLeft < scrollWidth - clientWidth - 1); - }; - - const updateWorkspaceWidth = () => { - if (!containerRef.current) return; - - const containerWidth = containerRef.current.offsetWidth; - const availableWidth = containerWidth - ADD_BUTTON_WIDTH; - - // Calculate width: fill available space but respect min/max - const calculatedWidth = Math.max( - MIN_WORKSPACE_WIDTH, - Math.min(MAX_WORKSPACE_WIDTH, availableWidth / allWorkspaces.length), - ); - setWorkspaceWidth(calculatedWidth); - }; - - checkScroll(); - updateWorkspaceWidth(); - - const scrollElement = scrollRef.current; - if (scrollElement) { - scrollElement.addEventListener("scroll", checkScroll); - } - - window.addEventListener("resize", updateWorkspaceWidth); - - return () => { - if (scrollElement) { - scrollElement.removeEventListener("scroll", checkScroll); - } - window.removeEventListener("resize", updateWorkspaceWidth); - }; - }, [allWorkspaces]); - - return ( -
-
-
- {groups.map((group, groupIndex) => ( - - - {groupIndex < groups.length - 1 && ( -
-
-
- )} - - ))} - {isSettingsTabOpen && ( - <> - {groups.length > 0 && ( -
-
-
- )} - - - )} -
- - {/* Left fade for scroll indication */} - {showStartFade && ( -
- )} - - {/* Right side: gradient fade + button container */} -
- {/* Gradient fade - only show when content overflows */} - {showEndFade && ( -
- )} - {/* Button with solid background */} -
- -
-
-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 81ca9aec30b..71b2285f76f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -25,12 +25,14 @@ import { useSetActiveWorkspace, useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; -import { BranchSwitcher } from "renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher"; -import { DeleteWorkspaceDialog } from "renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog"; -import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; -import { WorkspaceHoverCardContent } from "renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { + BranchSwitcher, + DeleteWorkspaceDialog, + WorkspaceHoverCardContent, +} from "./components"; import { GITHUB_STATUS_STALE_TIME, HOVER_CARD_CLOSE_DELAY, diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts new file mode 100644 index 00000000000..369ca719829 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts @@ -0,0 +1 @@ +export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts new file mode 100644 index 00000000000..282bf9f9a4b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts @@ -0,0 +1,3 @@ +export { BranchSwitcher } from "./BranchSwitcher"; +export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; +export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/desktop/src/renderer/screens/main/hooks/index.ts b/apps/desktop/src/renderer/screens/main/hooks/index.ts index 8337712ea57..4b8035bebba 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/index.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/index.ts @@ -1 +1 @@ -// +export { useWorkspaceRename } from "./useWorkspaceRename"; diff --git a/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts b/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts new file mode 100644 index 00000000000..4b8035bebba --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/index.ts @@ -0,0 +1 @@ +export { useWorkspaceRename } from "./useWorkspaceRename"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename.ts b/apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/useWorkspaceRename.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename.ts rename to apps/desktop/src/renderer/screens/main/hooks/useWorkspaceRename/useWorkspaceRename.ts From dfc9beee56d874039b53405cdb4baac93b0ea56c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 4 Jan 2026 18:46:06 -0800 Subject: [PATCH 59/64] visual --- .../WorkspaceSidebar/ProjectSection/ProjectHeader.tsx | 6 ------ .../WorkspaceListItem/WorkspaceListItem.tsx | 11 ++++++++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index c081a846194..ec36067e449 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -1,5 +1,4 @@ import { cn } from "@superset/ui/utils"; -import { LuChevronDown, LuChevronRight } from "react-icons/lu"; interface ProjectHeaderProps { projectName: string; @@ -25,11 +24,6 @@ export function ProjectHeader({ "text-left cursor-pointer", )} > - {isCollapsed ? ( - - ) : ( - - )} {projectName} {workspaceCount} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 71b2285f76f..b9b703641a1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -221,7 +221,7 @@ export function WorkspaceListItem({ isActive && "text-foreground font-medium", )} > - {name} + {name || branch} {pr && ( @@ -233,8 +233,13 @@ export function WorkspaceListItem({ )}
- {name !== branch && !isBranchWorkspace && ( -
+ {!isBranchWorkspace && ( +
{branch}
)} From d04cac7aee5dbc617abe41c126f63b8e62cfcb35 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 5 Jan 2026 09:38:45 -0800 Subject: [PATCH 60/64] fix null comparison in duplicate workspace detection --- .../local-db/drizzle/0006_add_unique_branch_workspace_index.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql index 23ba33cd833..ac308f5f3d4 100644 --- a/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql +++ b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql @@ -22,6 +22,7 @@ WHERE last_active_workspace_id IN ( w2.last_opened_at > w1.last_opened_at OR (w2.last_opened_at = w1.last_opened_at AND w2.id < w1.id) OR (w2.last_opened_at IS NOT NULL AND w1.last_opened_at IS NULL) + OR (w1.last_opened_at IS NULL AND w2.last_opened_at IS NULL AND w2.id < w1.id) ) ) ); From 0b9576bb5cebc434824a04b602a4f285567ea8d7 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 5 Jan 2026 09:57:05 -0800 Subject: [PATCH 61/64] fix comments --- .../workspaces/useSetActiveWorkspace.ts | 8 ++++++++ .../WorkspaceSidebar/WorkspaceSidebar.tsx | 20 ++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts index dfbff3ef05a..eb0d524c53d 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -18,6 +18,14 @@ export function useSetActiveWorkspace( return trpc.workspaces.setActive.useMutation({ ...options, + onError: (error, variables, context) => { + console.error("[workspace/setActive] Failed to set active workspace:", { + workspaceId: variables.id, + error: error.message, + }); + toast.error(`Failed to switch workspace: ${error.message}`); + options?.onError?.(error, variables, context); + }, onSuccess: async (data, variables, ...rest) => { // Auto-invalidate active workspace and all workspaces queries await Promise.all([ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 61b9170cba4..805144283ce 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; import { PortsList } from "./PortsList"; import { ProjectSection } from "./ProjectSection"; @@ -7,13 +8,18 @@ import { WorkspaceSidebarHeader } from "./WorkspaceSidebarHeader"; export function WorkspaceSidebar() { const { groups, activeWorkspaceId } = useWorkspaceShortcuts(); - // Calculate shortcut base indices for each project group - let shortcutIndex = 0; - const projectShortcutIndices = groups.map((group) => { - const baseIndex = shortcutIndex; - shortcutIndex += group.workspaces.length; - return baseIndex; - }); + // Calculate shortcut base indices for each project group using cumulative offsets + const projectShortcutIndices = useMemo( + () => + groups.reduce<{ indices: number[]; cumulative: number }>( + (acc, group) => ({ + indices: [...acc.indices, acc.cumulative], + cumulative: acc.cumulative + group.workspaces.length, + }), + { indices: [], cumulative: 0 }, + ).indices, + [groups], + ); return (
From 7c585261c0a72f1476e50ec317abb27a1622a37a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 5 Jan 2026 10:23:32 -0800 Subject: [PATCH 62/64] address comments --- .../WorkspaceListItem/WorkspaceListItem.tsx | 16 ++-- .../TabsContent/GroupStrip/GroupItem.tsx | 79 +++++++++++++++++++ .../TabsContent/GroupStrip/GroupStrip.tsx | 79 +------------------ .../desktop/src/renderer/stores/tabs/utils.ts | 6 +- 4 files changed, 95 insertions(+), 85 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index b9b703641a1..c7fb51da427 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -155,11 +155,17 @@ export function WorkspaceListItem({ accept: WORKSPACE_TYPE, hover: (item: { id: string; projectId: string; index: number }) => { if (item.projectId === projectId && item.index !== index) { - reorderWorkspaces.mutate({ - projectId, - fromIndex: item.index, - toIndex: index, - }); + reorderWorkspaces.mutate( + { + projectId, + fromIndex: item.index, + toIndex: index, + }, + { + onError: (error) => + toast.error(`Failed to reorder workspace: ${error.message}`), + }, + ); item.index = index; } }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx new file mode 100644 index 00000000000..67889785599 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -0,0 +1,79 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { HiMiniXMark } from "react-icons/hi2"; +import type { Tab } from "renderer/stores/tabs/types"; +import { getTabDisplayName } from "renderer/stores/tabs/utils"; + +interface GroupItemProps { + tab: Tab; + isActive: boolean; + needsAttention: boolean; + onSelect: () => void; + onClose: () => void; +} + +export function GroupItem({ + tab, + isActive, + needsAttention, + onSelect, + onClose, +}: GroupItemProps) { + const displayName = getTabDisplayName(tab); + + return ( +
+ + + + + + {displayName} + + + + + + + + Close group + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 77790ae7b88..9e068c97f8c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -1,85 +1,10 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; import { useMemo } from "react"; -import { HiMiniPlus, HiMiniXMark } from "react-icons/hi2"; +import { HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { Tab } from "renderer/stores/tabs/types"; -import { getTabDisplayName } from "renderer/stores/tabs/utils"; - -interface GroupItemProps { - tab: Tab; - isActive: boolean; - needsAttention: boolean; - onSelect: () => void; - onClose: () => void; -} - -function GroupItem({ - tab, - isActive, - needsAttention, - onSelect, - onClose, -}: GroupItemProps) { - const displayName = getTabDisplayName(tab); - - return ( -
- - - - - - {displayName} - - - - - - - - Close group - - -
- ); -} +import { GroupItem } from "./GroupItem"; export function GroupStrip() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 77eecc76c1f..62ee90aad7b 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -7,6 +7,8 @@ import type { } from "shared/tabs-types"; import type { Pane, PaneType, Tab } from "./types"; +const MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"] as const; + /** * Generates a unique ID with the given prefix */ @@ -119,9 +121,7 @@ export const createFileViewerPane = ( if (options.diffCategory) { defaultViewMode = "diff"; } else if ( - options.filePath.endsWith(".md") || - options.filePath.endsWith(".markdown") || - options.filePath.endsWith(".mdx") + MARKDOWN_EXTENSIONS.some((ext) => options.filePath.endsWith(ext)) ) { defaultViewMode = "rendered"; } From 3d17223190226220f4d255be9cae38cea81f7ff8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 5 Jan 2026 10:27:11 -0800 Subject: [PATCH 63/64] address comments --- .../react-query/workspaces/useSetActiveWorkspace.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts index 8c19b68824f..debb3a08b2a 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -14,6 +14,12 @@ export function useSetActiveWorkspace( onSuccess: () => { utils.workspaces.getAllGrouped.invalidate(); }, + onError: (error) => { + console.error("[workspace/setUnread] Failed to update unread status:", { + error: error.message, + }); + toast.error(`Failed to undo: ${error.message}`); + }, }); return trpc.workspaces.setActive.useMutation({ From a0333b28a82b5d62f68a64d70324c132350bf437 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 5 Jan 2026 10:57:00 -0800 Subject: [PATCH 64/64] Remove redundant comments --- apps/desktop/src/lib/trpc/routers/changes/branches.ts | 3 --- .../src/lib/trpc/routers/changes/file-contents.ts | 7 ------- .../src/lib/trpc/routers/changes/git-operations.ts | 5 ----- apps/desktop/src/lib/trpc/routers/changes/staging.ts | 1 - apps/desktop/src/lib/trpc/routers/changes/status.ts | 9 --------- 5 files changed, 25 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index b2351e98bdd..bda18dccf69 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -66,10 +66,7 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // Get worktree record for updating branch info const worktree = getRegisteredWorktree(input.worktreePath); - - // Use gitSwitchBranch which uses `git switch` (correct branch syntax) await gitSwitchBranch(input.worktreePath, input.branch); // Update the branch in the worktree record diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 4b2ae5aca2c..8967db3fc2e 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -89,7 +89,6 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // secureFs.writeFile validates worktree registration and path traversal await secureFs.writeFile( input.worktreePath, input.filePath, @@ -111,19 +110,16 @@ export const createFileContentsRouter = () => { ) .query(async ({ input }): Promise => { try { - // Check file size first (uses stat which follows symlinks) const stats = await secureFs.stat(input.worktreePath, input.filePath); if (stats.size > MAX_FILE_SIZE) { return { ok: false, reason: "too-large" }; } - // Read file content as buffer for binary detection const buffer = await secureFs.readFileBuffer( input.worktreePath, input.filePath, ); - // Check for binary content if (isBinaryContent(buffer)) { return { ok: false, reason: "binary" }; } @@ -136,13 +132,11 @@ export const createFileContentsRouter = () => { }; } catch (error) { if (error instanceof PathValidationError) { - // Map specific error codes to distinct reasons if (error.code === "SYMLINK_ESCAPE") { return { ok: false, reason: "symlink-escape" }; } return { ok: false, reason: "outside-worktree" }; } - // File not found or other read error return { ok: false, reason: "not-found" }; } }), @@ -263,7 +257,6 @@ async function getUnstagedVersions( let modified = ""; try { - // Check file size before reading (uses stat which follows symlinks) const stats = await secureFs.stat(worktreePath, filePath); if (stats.size <= MAX_FILE_SIZE) { modified = await secureFs.readFile(worktreePath, filePath); diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 35f58d7c956..c69364a3406 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -32,7 +32,6 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; hash: string }> => { - // SECURITY: Validate worktreePath exists in localDb assertRegisteredWorktree(input.worktreePath); const git = simpleGit(input.worktreePath); @@ -49,7 +48,6 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // SECURITY: Validate worktreePath exists in localDb assertRegisteredWorktree(input.worktreePath); const git = simpleGit(input.worktreePath); @@ -72,7 +70,6 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // SECURITY: Validate worktreePath exists in localDb assertRegisteredWorktree(input.worktreePath); const git = simpleGit(input.worktreePath); @@ -98,7 +95,6 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // SECURITY: Validate worktreePath exists in localDb assertRegisteredWorktree(input.worktreePath); const git = simpleGit(input.worktreePath); @@ -128,7 +124,6 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; url: string }> => { - // SECURITY: Validate worktreePath exists in localDb assertRegisteredWorktree(input.worktreePath); const git = simpleGit(input.worktreePath); diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 037227fa4f8..678e1304c88 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -69,7 +69,6 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - // secureFs.delete validates worktree registration and path traversal await secureFs.delete(input.worktreePath, input.filePath); return { success: true }; }), diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index 9b79a161a71..2916e06098b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -147,21 +147,12 @@ async function getBranchComparison( /** Max file size for line counting (1 MiB) - skip larger files to avoid OOM */ const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; -/** - * Apply line counts to untracked files. - * - * Uses secureFs which: - * - Validates paths don't escape worktree - * - Uses stat (follows symlinks) for accurate size checks - * - Checks for symlink escapes - */ async function applyUntrackedLineCount( worktreePath: string, untracked: ChangedFile[], ): Promise { for (const file of untracked) { try { - // secureFs.stat uses stat (follows symlinks) for accurate size const stats = await secureFs.stat(worktreePath, file.path); if (stats.size > MAX_LINE_COUNT_SIZE) continue;