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)