From 2cfa96a909c528eb680eef0481e39e57c12fcafe Mon Sep 17 00:00:00 2001 From: luffy <1292867089@qq.com> Date: Thu, 4 Jun 2026 16:07:52 +0800 Subject: [PATCH] feat: reveal workspace paths in the system file manager This PR addresses a desktop workflow gap where the file tree context menu could only add file or folder references to chat. During development, users also need to locate the selected item in the native file manager, such as Finder on macOS, File Explorer on Windows, or the default file manager on Linux. The change adds a file tree context menu action for both files and folders. The frontend displays platform-aware localized labels, calls the existing RevealWorkspacePath bridge, reports failures through the existing notice flow, and clears stale browser text selection when opening the context menu. The backend now keeps App.RevealWorkspacePath focused on workspace path validation and delegates platform command construction to a dedicated helper. macOS uses open -R, Windows uses explorer /select,, and Linux opens the parent folder for files or the folder itself for directories. Cross-platform command tests were added to reduce regression risk. --- desktop/app.go | 14 +---- desktop/frontend/src/App.tsx | 1 + .../src/components/WorkspacePanel.tsx | 48 ++++++++++++++++- desktop/frontend/src/locales/en.ts | 4 ++ desktop/frontend/src/locales/zh.ts | 4 ++ desktop/reveal_workspace.go | 28 ++++++++++ desktop/workspace_test.go | 54 +++++++++++++++++++ 7 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 desktop/reveal_workspace.go diff --git a/desktop/app.go b/desktop/app.go index e0b2bc738..74b68f8a7 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -7,7 +7,6 @@ import ( "io" "log/slog" "os" - "os/exec" "path/filepath" goruntime "runtime" "sort" @@ -2070,18 +2069,7 @@ func (a *App) RevealWorkspacePath(rel string) error { if err != nil || !ok { return os.ErrInvalid } - switch goruntime.GOOS { - case "darwin": - return exec.Command("open", "-R", path).Start() - case "windows": - return exec.Command("explorer", "/select,", path).Start() - default: - dir := path - if info, err := os.Stat(path); err == nil && !info.IsDir() { - dir = filepath.Dir(path) - } - return exec.Command("xdg-open", dir).Start() - } + return revealWorkspacePath(goruntime.GOOS, path) } func (a *App) notice(text string) { diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 30ac745e3..fdd9b6024 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1036,6 +1036,7 @@ export default function App() { onToggleMaximized={() => setWorkspacePanelMaximized((value) => !value)} onPreviewModeChange={handleWorkspacePreviewModeChange} onAddToChat={addToChat} + onNotice={notice} changesRefreshKey={workspaceChangesRefreshKey} /> diff --git a/desktop/frontend/src/components/WorkspacePanel.tsx b/desktop/frontend/src/components/WorkspacePanel.tsx index 2ba620d25..de5b5df19 100644 --- a/desktop/frontend/src/components/WorkspacePanel.tsx +++ b/desktop/frontend/src/components/WorkspacePanel.tsx @@ -12,6 +12,7 @@ import { Columns2, FileText, Folder, + FolderSearch, GitBranch, Maximize2, MessageSquarePlus, @@ -37,7 +38,8 @@ const WORKSPACE_TREE_MIN_WIDTH = 220; const WORKSPACE_TREE_DEFAULT_WIDTH = WORKSPACE_TREE_MIN_WIDTH; const WORKSPACE_TREE_MAX_WIDTH = 420; const WORKSPACE_PREVIEW_MIN_WIDTH = 420; -const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 92; +const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136; +const WORKSPACE_CONTEXT_MENU_FOLDER_HEIGHT = 92; const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 48; function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number { @@ -135,6 +137,12 @@ function formatBytes(n: number): string { return `${n} B`; } +function revealMenuKey(platform: string): "workspace.revealInFinder" | "workspace.revealInExplorer" | "workspace.revealInFileManager" { + if (platform === "darwin") return "workspace.revealInFinder"; + if (platform === "windows") return "workspace.revealInExplorer"; + return "workspace.revealInFileManager"; +} + function isDeletedChange(row: WorkspaceChangeView): boolean { return !!row.gitStatus && row.gitStatus.includes("D"); } @@ -155,6 +163,7 @@ export function WorkspacePanel({ onToggleMaximized, onPreviewModeChange, onAddToChat, + onNotice, changesRefreshKey, }: { open: boolean; @@ -165,6 +174,7 @@ export function WorkspacePanel({ onToggleMaximized: () => void; onPreviewModeChange?: (active: boolean) => void; onAddToChat?: (text: string) => void; + onNotice?: (text: string, level?: "info" | "warn") => void; changesRefreshKey?: number; }) { const t = useT(); @@ -187,12 +197,28 @@ export function WorkspacePanel({ const [treeVisible, setTreeVisible] = useState(true); const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth); const [treeResizing, setTreeResizing] = useState(false); + const [platform, setPlatform] = useState(""); const loadDir = useCallback(async (dir: string) => { const entries = await app.ListDir(dir).catch(() => []); setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] })); }, []); + useEffect(() => { + let live = true; + app + .Platform() + .then((next) => { + if (live) setPlatform(next); + }) + .catch(() => { + if (live) setPlatform(""); + }); + return () => { + live = false; + }; + }, []); + const loadChanges = useCallback(async () => { const requestId = changesRequestRef.current + 1; changesRequestRef.current = requestId; @@ -474,6 +500,7 @@ export function WorkspacePanel({ const openTreeMenu = (event: ReactMouseEvent, path: string, isDir: boolean) => { event.preventDefault(); event.stopPropagation(); + window.getSelection()?.removeAllRanges(); setSelectionMenu(null); setTreeMenu({ x: event.clientX, y: event.clientY, path, isDir }); }; @@ -508,6 +535,18 @@ export function WorkspacePanel({ } }; + const revealTreePath = async () => { + if (!treeMenu) return; + const target = treeMenu.path; + setTreeMenu(null); + try { + await app.RevealWorkspacePath(target); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + onNotice?.(t("workspace.revealFailed", { path: target, err: detail }), "warn"); + } + }; + const renderChangedRows = () => { if (loadingChanges) return
{t("workspace.loadingChanges")}
; if (!changes) return null; @@ -819,7 +858,7 @@ export function WorkspacePanel({ , + label: t(revealMenuKey(platform)), + onSelect: () => void revealTreePath(), + }, ...(treeMenu.isDir ? [] : [ diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index c49d861b0..1a22f1fee 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -64,6 +64,10 @@ export const en = { "workspace.addFileReferenceToChat": "Add file reference", "workspace.addFolderReferenceToChat": "Add folder reference", "workspace.addFileContentToChat": "Add file contents", + "workspace.revealInFinder": "Reveal in Finder", + "workspace.revealInExplorer": "Show in File Explorer", + "workspace.revealInFileManager": "Show in file manager", + "workspace.revealFailed": "Could not show {path}: {err}", "workspace.viewMode": "Workspace view", "workspace.filesTab": "Files", "workspace.changedTab": "Changed", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index cd046238e..e33d8dd5d 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -65,6 +65,10 @@ export const zh: Record = { "workspace.addFileReferenceToChat": "添加文件引用", "workspace.addFolderReferenceToChat": "添加文件夹引用", "workspace.addFileContentToChat": "添加文件内容", + "workspace.revealInFinder": "在 Finder 中显示", + "workspace.revealInExplorer": "在资源管理器中显示", + "workspace.revealInFileManager": "在文件管理器中显示", + "workspace.revealFailed": "无法显示 {path}:{err}", "workspace.viewMode": "工作区视图", "workspace.filesTab": "文件", "workspace.changedTab": "改动", diff --git a/desktop/reveal_workspace.go b/desktop/reveal_workspace.go new file mode 100644 index 000000000..0c2945274 --- /dev/null +++ b/desktop/reveal_workspace.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" +) + +func revealWorkspaceCommand(goos string, path string, isDir bool) *exec.Cmd { + switch goos { + case "darwin": + return exec.Command("open", "-R", path) + case "windows": + return exec.Command("explorer", "/select,"+path) + default: + target := path + if !isDir { + target = filepath.Dir(path) + } + return exec.Command("xdg-open", target) + } +} + +func revealWorkspacePath(goos string, path string) error { + info, err := os.Stat(path) + isDir := err == nil && info.IsDir() + return revealWorkspaceCommand(goos, path, isDir).Start() +} diff --git a/desktop/workspace_test.go b/desktop/workspace_test.go index c1b0db6cb..8deb2b625 100644 --- a/desktop/workspace_test.go +++ b/desktop/workspace_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" @@ -184,6 +185,59 @@ func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) { } } +func TestRevealWorkspaceCommandByPlatform(t *testing.T) { + tests := []struct { + name string + goos string + path string + isDir bool + wantName string + wantArgs []string + }{ + { + name: "macOS reveals files with Finder selection", + goos: "darwin", + path: "/repo/src/main.go", + wantName: "open", + wantArgs: []string{"open", "-R", "/repo/src/main.go"}, + }, + { + name: "Windows reveals files with Explorer selection", + goos: "windows", + path: `C:\repo\src\main.go`, + wantName: "explorer", + wantArgs: []string{"explorer", `/select,C:\repo\src\main.go`}, + }, + { + name: "Linux reveals files by opening parent folder", + goos: "linux", + path: "/repo/src/main.go", + wantName: "xdg-open", + wantArgs: []string{"xdg-open", "/repo/src"}, + }, + { + name: "Linux opens folders directly", + goos: "linux", + path: "/repo/src", + isDir: true, + wantName: "xdg-open", + wantArgs: []string{"xdg-open", "/repo/src"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := revealWorkspaceCommand(tt.goos, tt.path, tt.isDir) + if filepath.Base(cmd.Path) != tt.wantName { + t.Fatalf("command path = %q, want basename %q", cmd.Path, tt.wantName) + } + if !reflect.DeepEqual(cmd.Args, tt.wantArgs) { + t.Fatalf("command args = %#v, want %#v", cmd.Args, tt.wantArgs) + } + }) + } +} + func TestParseGitStatusPorcelainZ(t *testing.T) { raw := []byte(" M changed.go\x00?? new.txt\x00R renamed.go\x00old.go\x00") got := parseGitStatusPorcelainZ(raw)