Merge pull request #8076 from SivanCola/feature/workspace-copy-paths

feat(desktop): add workspace path copy actions / 桌面端新增工作区路径复制操作
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-10 01:52:37 +08:00
commit 95b5ef985c
9 files changed
+209 -7

No files matched your search

@@ -0,0 +1,64 @@
// Run: tsx src/__tests__/workspace-path-copy.test.tsx
import { workspacePathCopyMenuItems } from "../lib/workspacePathCopyMenuItems";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
if (value) passed += 1;
else failed += 1;
}
const clipboardWrites: string[] = [];
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText: async (value: string) => {
clipboardWrites.push(value);
},
},
},
});
let closeCalls = 0;
let resolveCalls = 0;
let scopeCurrent = true;
const items = workspacePathCopyMenuItems({
path: "src/",
resolveAbsolutePath: async () => {
resolveCalls += 1;
return "/repo/src";
},
isScopeCurrent: () => scopeCurrent,
close: () => {
closeCalls += 1;
},
relativeLabel: "Copy relative path",
absoluteLabel: "Copy absolute path",
});
console.log("\nworkspace path copy menu");
ok(items.length === 2, "builds both path-copy actions");
ok(items[0]?.label === "Copy relative path", "exposes the localized relative-path label");
ok(items[1]?.label === "Copy absolute path", "exposes the localized absolute-path label");
items[0]?.onSelect();
await new Promise((resolve) => setTimeout(resolve, 0));
ok(clipboardWrites[0] === "src", "folder relative paths omit the tree-only trailing slash");
ok(closeCalls === 1, "relative-path selection closes the menu");
items[1]?.onSelect();
await new Promise((resolve) => setTimeout(resolve, 0));
ok(resolveCalls === 1, "absolute-path selection uses backend resolution");
ok(clipboardWrites[1] === "/repo/src", "copies the backend-resolved absolute path");
scopeCurrent = false;
items[1]?.onSelect();
await new Promise((resolve) => setTimeout(resolve, 0));
ok(resolveCalls === 2 && clipboardWrites.length === 2, "drops stale absolute-path results after the workspace scope changes");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -60,6 +60,7 @@ import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
import { cleanGitDiff } from "../lib/diff";
import { WORKSPACE_CONTEXT_MENU_FILE_HEIGHT, WORKSPACE_CONTEXT_MENU_REF_HEIGHT, workspacePathCopyMenuItems } from "../lib/workspacePathCopyMenuItems";
import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
@@ -75,8 +76,6 @@ const WORKSPACE_TREE_RAIL_WIDTH = 44;
const WORKSPACE_PREVIEW_MIN_WIDTH = 140;
const WORKSPACE_PREVIEW_TARGET_WIDTH = 360;
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136;
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 92;
const WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT = 48;
const WORKSPACE_MAX_PREVIEW_TABS = 5;
@@ -2123,6 +2122,7 @@ export function WorkspacePanel({
onSelect: () => void addTreeFileToChat(),
},
]),
...workspacePathCopyMenuItems({ path: treeMenu.path, resolveAbsolutePath: () => app.ResolveWorkspacePathForTab(workspaceTabId, treeMenu.path), isScopeCurrent: () => currentWorkspaceScopeKeyRef.current === workspaceScopeKey, close: () => setTreeMenu(null), relativeLabel: t("workspace.copyRelativePath"), absoluteLabel: t("workspace.copyAbsolutePath") }),
{
icon: <FolderOpen size={14} />,
label: t("workspace.revealInFileManager"),
+5 -5
View File
@@ -1,8 +1,6 @@
// bridge is the single seam between the React app and the Go kernel. In the Wails
// shell it calls the bound App methods (window.go.main.App.*) and subscribes to
// the runtime event stream (window.runtime.EventsOn). In a plain browser (`pnpm
// dev` outside the shell) those globals are absent, so it falls back to a mock
// that streams a canned turn through the same contract — letting the whole UI be
// bridge is the seam between React and the Go kernel. The Wails shell calls bound
// App methods and subscribes to runtime events; in a plain browser (`pnpm dev`),
// a mock streams a canned turn through the same contract so the whole UI can be
// developed and laid out without rebuilding the Go side.
// @ts-ignore `wails generate module` creates this locally; fresh checkouts keep
@@ -388,6 +386,7 @@ export interface AppBindings {
WorkspaceGitCommitDetail(tabID: string, hash: string, path: string): Promise<GitCommitDetailView>;
OpenWorkspacePath(rel: string): Promise<void>;
OpenWorkspacePathForTab(tabID: string, rel: string): Promise<void>;
ResolveWorkspacePathForTab(tabID: string, rel: string): Promise<string>;
ExternalOpeners(): Promise<ExternalOpenersView>;
SetPreferredExternalOpener(id: string): Promise<void>;
OpenWorkspaceInExternalOpener(id: string): Promise<void>;
@@ -3982,6 +3981,7 @@ function makeMockApp(): AppBindings {
async OpenWorkspacePathForTab(_tabID: string, rel: string) {
await this.OpenWorkspacePath(rel);
},
async ResolveWorkspacePathForTab(_tabID: string, rel: string) { return `${cwd.replace(/[\\/]+$/, "")}/${rel.replace(/^[/\\]+/, "").replace(/[\\/]+$/, "")}`; },
async ExternalOpeners() {
return {
openers: [
@@ -0,0 +1,48 @@
import { Copy } from "lucide-react";
import type { FloatingMenuItem } from "../components/FloatingMenu";
import { writeClipboardText } from "./clipboard";
export const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 204;
export const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 160;
export function workspacePathCopyMenuItems({
path,
resolveAbsolutePath,
isScopeCurrent,
close,
relativeLabel,
absoluteLabel,
}: {
path: string;
resolveAbsolutePath: () => Promise<string>;
isScopeCurrent: () => boolean;
close: () => void;
relativeLabel: string;
absoluteLabel: string;
}): FloatingMenuItem[] {
return [
{
icon: <Copy size={14} />,
label: relativeLabel,
onSelect: () => {
const relativePath = path.replace(/\/+$/, "");
close();
if (relativePath) void writeClipboardText(relativePath);
},
},
{
icon: <Copy size={14} />,
label: absoluteLabel,
onSelect: () => {
close();
void resolveAbsolutePath()
.then((absolutePath) => {
if (absolutePath && isScopeCurrent()) return writeClipboardText(absolutePath);
})
.catch(() => {
// The path may disappear between opening the menu and selecting it.
});
},
},
];
}
+2
View File
@@ -255,6 +255,8 @@ export const en = {
"workspace.addFileReferenceToChat": "Add file reference",
"workspace.addFolderReferenceToChat": "Add folder reference",
"workspace.addFileContentToChat": "Add file contents",
"workspace.copyRelativePath": "Copy relative path",
"workspace.copyAbsolutePath": "Copy absolute path",
"workspace.viewMode": "Workspace view",
"workspace.filesTab": "Files",
"workspace.changedTab": "Changes",
+2
View File
@@ -175,6 +175,8 @@ export const zhTW: Record<DictKey, string> = {
"workspace.addFileReferenceToChat": "新增檔案引用",
"workspace.addFolderReferenceToChat": "新增資料夾引用",
"workspace.addFileContentToChat": "新增檔案內容",
"workspace.copyRelativePath": "複製相對路徑",
"workspace.copyAbsolutePath": "複製絕對路徑",
"workspace.viewMode": "工作區視圖",
"workspace.filesTab": "檔案",
"workspace.changedTab": "變更",
+2
View File
@@ -256,6 +256,8 @@ export const zh: Record<DictKey, string> = {
"workspace.addFileReferenceToChat": "添加文件引用",
"workspace.addFolderReferenceToChat": "添加文件夹引用",
"workspace.addFileContentToChat": "添加文件内容",
"workspace.copyRelativePath": "复制相对路径",
"workspace.copyAbsolutePath": "复制绝对路径",
"workspace.viewMode": "工作区视图",
"workspace.filesTab": "文件",
"workspace.changedTab": "改动",
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"os"
"path/filepath"
)
// ResolveWorkspacePathForTab returns the absolute local path represented by a
// workspace-relative path or a session-authorized external folder reference.
// Keeping this resolution in the backend ensures the renderer cannot mistake an
// external reference token for a child of the workspace root.
func (a *App) ResolveWorkspacePathForTab(tabID, rel string) (string, error) {
path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
if err != nil {
return "", err
}
if !ok {
return "", os.ErrInvalid
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/control"
)
func TestResolveWorkspacePathForTab(t *testing.T) {
parentRoot := robustTempDir(t)
childRoot := filepath.Join(parentRoot, "child")
if err := os.MkdirAll(childRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child"), 0o644); err != nil {
t.Fatal(err)
}
app := &App{tabs: map[string]*WorkspaceTab{
"child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
}}
absolutePath, err := app.ResolveWorkspacePathForTab("child", "shared.txt")
if err != nil || absolutePath != filepath.Join(childRoot, "shared.txt") {
t.Fatalf("ResolveWorkspacePathForTab(child) = (%q, %v)", absolutePath, err)
}
if _, err := app.ResolveWorkspacePathForTab("missing", "shared.txt"); err == nil {
t.Fatal("ResolveWorkspacePathForTab accepted a missing tab")
}
if _, err := app.ResolveWorkspacePathForTab("child", "../parent-only.txt"); err == nil {
t.Fatal("ResolveWorkspacePathForTab accepted a path outside the requested tab workspace")
}
}
func TestResolveWorkspacePathForTabExternalFolder(t *testing.T) {
external := filepath.Join(robustTempDir(t), "Folder With Spaces")
if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
t.Fatal(err)
}
expectedExternal := external
if resolved, err := filepath.EvalSymlinks(external); err == nil {
expectedExternal = resolved
}
ctrl := &control.Controller{}
token, _, err := ctrl.RegisterExternalFolderRef(external)
if err != nil {
t.Fatalf("RegisterExternalFolderRef: %v", err)
}
app := &App{tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: robustTempDir(t), Ctrl: ctrl},
}}
absolutePath, err := app.ResolveWorkspacePathForTab("project", token+"/src/outside.txt")
want := filepath.Join(expectedExternal, "src", "outside.txt")
if err != nil || absolutePath != want {
t.Fatalf("ResolveWorkspacePathForTab external = (%q, %v), want %q", absolutePath, err, want)
}
}