Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andJacek Tomaszewski a8032a1502 fix(app): open outside folder links without breaking Files (#7726)
Outside folder links were routed to the workspace-scoped Files explorer,
which kept an access error until Retry. Route explicit outside folders
to the local file manager or show an outside-workspace message, while
preserving existing file and inside-folder behavior.

Refs #7513

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-10-27 10:57:58 +00:00
21 changed files with 143 additions and 151 deletions
+4 -1
View File
@@ -90,6 +90,7 @@ import {
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import {
createWorkspaceFileTabTarget,
type WorkspaceFileLocation,
@@ -459,6 +460,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files",
});
});
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -466,8 +468,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory],
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
await pending;
expect(opened).toEqual([]);
});
it("sends folders outside the workspace away from the workspace Files panel", async () => {
const { createInlinePathOpener } = await import("./path-open");
const workspaceFolders: string[] = [];
const outsideFolders: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/Users/me/repo",
client: null,
openFile: () => {},
openDirectory: (directory) => {
workspaceFolders.push(directory);
},
openOutsideDirectory: (directory) => {
outsideFolders.push(directory);
},
});
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
await opener.open({ raw, path: raw }, "preferred");
}
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
@@ -14,12 +14,23 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
type PathOpenTarget =
| { kind: "file" }
| { kind: "directory"; directoryPath: string }
| { kind: "outsideDirectory"; directoryPath: string };
// Workspace paths arrive relative to the root; anything still absolute or
// home-relative lives outside it, where the Files panel cannot list it.
function isOutsideWorkspacePath(pathValue: string): boolean {
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
}
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return { kind: "directory", directoryPath: input.directory };
return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
}) {
let generation = 0;
return {
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
+1
View File
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
+1
View File
@@ -63,6 +63,7 @@ export const en = {
daemonClientUnavailable: "Daemon client unavailable",
daemonClientDisconnected: "Daemon client is disconnected",
noFileFound: "No file found for {{token}}",
folderOutsideWorkspace: "{{path}} is outside this workspace",
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const es: TranslationResources = {
daemonClientUnavailable: "ClienteDaemonno disponible",
daemonClientDisconnected: "El clienteDaemonestá desconectado",
noFileFound: "No se encontró ningún archivo para{{token}}",
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const fr: TranslationResources = {
daemonClientUnavailable: "Client du daemon indisponible",
daemonClientDisconnected: "Le client du daemon est déconnecté",
noFileFound: "Aucun fichier trouvé pour {{token}}",
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
+1
View File
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const ptBR: TranslationResources = {
daemonClientUnavailable: "Cliente do daemon indisponível",
daemonClientDisconnected: "O cliente do daemon está desconectado",
noFileFound: "Nenhum arquivo encontrado para {{token}}",
folderOutsideWorkspace: "{{path}} está fora deste workspace",
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
},
connectionStatus: {
+1
View File
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
+1
View File
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -64,6 +64,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -3682,6 +3683,7 @@ function WorkspaceScreenContent({
view: "files",
});
});
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -3689,8 +3691,15 @@ function WorkspaceScreenContent({
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
openOutsideDirectory: openMarkdownOutsideDirectory,
}),
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
[
workspaceDirectory,
client,
openMarkdownFile,
openMarkdownDirectory,
openMarkdownOutsideDirectory,
],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
@@ -0,0 +1,36 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
/**
* Opens a folder outside the workspace in the system file manager. The Files
* panel only lists the workspace, so hosts without a local file manager get a
* toast instead.
*/
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
const { t } = useTranslation();
const toast = useToast();
const isLocalExecution = useIsLocalDaemon(serverId);
const { targets } = useDesktopOpenTargets({ isLocalExecution });
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
return useCallback(
(folderPath: string) => {
const showOutsideWorkspace = () =>
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
if (!fileManagerTarget) {
showOutsideWorkspace();
return;
}
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
(error: unknown) => {
console.warn("[open-outside-workspace-folder] open failed", error);
showOutsideWorkspace();
},
);
},
[fileManagerTarget, t, toast],
);
}
@@ -86,6 +86,10 @@ class FakeEditorTargets implements EditorTargetRuntime {
return this.paths.has(targetPath);
}
expandHomePath(targetPath: string): string {
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
}
isAbsolutePath(targetPath: string): boolean {
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
}
@@ -513,6 +517,19 @@ describe("editor target registry", () => {
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
});
it("opens a home-relative folder in the file manager", async () => {
const runtime = new FakeEditorTargets();
runtime.addPath("/home/me/Downloads/contracts");
await openEditorTarget(
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
runtime,
[fileManagerTarget],
);
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
});
it("keeps the platform file-manager ids used by stored preferences", async () => {
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
finderTarget,
@@ -97,10 +97,16 @@ export function getEditorTarget(
}
export async function openEditorTarget(
input: EditorTargetLaunchInput & { editorId: string },
request: EditorTargetLaunchInput & { editorId: string },
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<void> {
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
const input = {
...request,
workspacePath: runtime.expandHomePath(request.workspacePath),
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
};
if (!runtime.isAbsolutePath(input.workspacePath)) {
throw new Error("Editor target workspace path must be an absolute local path");
}
@@ -61,6 +61,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function expandHomePath(value: string, homeDirectory: string): string {
if (value === "~") return homeDirectory;
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(homeDirectory, value.slice(2));
}
return value;
}
function resolveExecutable(
commands: readonly string[],
input: {
@@ -166,6 +174,7 @@ export function createEditorTargetRuntime(
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
listDirectory,
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
@@ -30,6 +30,8 @@ export interface EditorTargetRuntime {
pathExists(path: string): boolean;
isAbsolutePath(path: string): boolean;
listDirectory(path: string): EditorTargetDirectoryEntry[];
/** Resolves a leading `~` to the user's home directory. */
expandHomePath(path: string): string;
resolveCommand(commands: readonly string[]): string | null;
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
openPath(path: string): Promise<void>;
@@ -651,8 +651,7 @@ lines.on("line", (line) => {
type: "timeline.item",
item: expect.objectContaining({
type: "assistant_message",
id: JSON.stringify(["agent_message_chunk", "assistant-1"]),
messageId: "assistant-1",
id: "assistant-1",
text: "ACP says hi",
}),
}),
@@ -1,8 +1,4 @@
import { describe, expect, test } from "vitest";
import { createServer } from "node:http";
import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
diffWorkspaceGitRefs,
@@ -10,21 +6,14 @@ import {
fetchWorkspaceGitRemote,
parseWorkspaceGitRefs,
} from "./workspace-git-fetch.js";
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
import type { RunGitCommand } from "../utils/run-git-command.js";
describe("workspace Git remote refs", () => {
test("runs the complete fetch through the supplied Git runner", async () => {
const commands: string[][] = [];
let refRead = 0;
const run: RunGitCommand = async (args, options) => {
const runGitCommand: RunGitCommand = async (args) => {
commands.push(args);
if (args[0] === "fetch") {
expect(options.envOverlay).toEqual({
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
GCM_INTERACTIVE: "0",
});
}
const stdout =
args[0] === "for-each-ref"
? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}`
@@ -33,7 +22,7 @@ describe("workspace Git remote refs", () => {
};
await expect(
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run),
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand),
).resolves.toMatchObject({
changes: [
{
@@ -120,130 +109,3 @@ describe("workspace Git remote refs", () => {
]);
});
});
// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication.
// Both the authentication challenge and successful fetch exercise Git itself.
async function withAuthenticatedRemote(
verify: (fixture: {
cwd: string;
run: RunGitCommand;
root: string;
origin: string;
expectedOid: string;
}) => Promise<void>,
): Promise<void> {
const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-"));
const cwd = join(root, "checkout");
const remote = join(root, "remote.git");
const emptyConfig = join(root, "gitconfig");
const askpass = join(root, "askpass.sh");
const marker = join(root, "prompts");
await writeFile(emptyConfig, "");
await writeFile(
askpass,
`#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`,
);
await chmod(askpass, 0o755);
const askpassCommand = askpass;
const run: RunGitCommand = (args, options) =>
runGitCommand(args, {
...options,
env: {
...options.env,
GIT_CONFIG_GLOBAL: emptyConfig,
GIT_CONFIG_NOSYSTEM: "1",
GIT_ASKPASS: askpassCommand,
SSH_ASKPASS: askpassCommand,
},
});
const server = createServer(async (request, response) => {
if (
request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}`
) {
response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' });
response.end();
return;
}
const pathname = new URL(request.url!, "http://localhost").pathname;
try {
const contents = await readFile(join(root, pathname));
response.writeHead(200, { "Content-Type": "application/octet-stream" });
response.end(contents);
} catch {
response.writeHead(404);
response.end();
}
});
try {
await run(["init", "--initial-branch=main", cwd], { cwd: root });
await run(
[
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd },
);
await run(["clone", "--bare", cwd, remote], { cwd: root });
await run(["update-server-info"], { cwd: remote });
await run(["config", "credential.helper", ""], { cwd });
await run(["config", "core.askPass", askpassCommand], { cwd });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP server");
const origin = `http://127.0.0.1:${address.port}/remote.git`;
await run(["remote", "add", "origin", origin], { cwd });
const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim();
await verify({ cwd, run, root, origin, expectedOid });
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
test.skipIf(process.platform === "win32")(
"background fetches never invoke askpass when credentials are missing",
async () => {
await withAuthenticatedRemote(async ({ cwd, run, root }) => {
for (let attempt = 0; attempt < 2; attempt++) {
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
expect(String(result.error)).toContain("terminal prompts disabled");
}
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
},
);
test.skipIf(process.platform === "win32")(
"background fetches still use stored credentials",
async () => {
await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => {
const credentials = join(root, "credentials");
await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n");
await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd });
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
expect(result.error).toBeNull();
expect(result.changes).toContainEqual({
kind: "added",
ref: "origin/main",
afterOid: expectedOid,
});
expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe(
expectedOid,
);
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
},
);
@@ -33,9 +33,7 @@ export async function fetchWorkspaceGitRemote(
try {
await run(["fetch", "origin", "--prune"], {
cwd,
// Terminal prompting and graphical credential prompting have separate controls.
// Keep cached credentials available, but never start a login from a passive fetch.
envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" },
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
timeout: 120_000,
});
} catch (caught) {