mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #8076 from SivanCola/feature/workspace-copy-paths
feat(desktop): add workspace path copy actions / 桌面端新增工作区路径复制操作
This commit is contained in:
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"),
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "變更",
|
||||
|
||||
@@ -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": "改动",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user