Open workspace files in desktop targets
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
resolveWorkspaceFilePaths,
|
||||
workspaceFileLocationsEqual,
|
||||
} from ".";
|
||||
|
||||
@@ -61,3 +62,93 @@ describe("workspace file tab targets", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceFilePaths", () => {
|
||||
it("joins workspace-relative paths against the workspace root", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the relative path for an absolute file inside the workspace", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "/Users/me/repo/src/app.ts",
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the absolute path but drops the relative path when outside the workspace", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "/etc/hosts",
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "/etc/hosts",
|
||||
relativePath: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Windows separators in the file path", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes dot segments in workspace-relative paths", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "./src/../app.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
).toEqual({
|
||||
absolutePath: "/Users/me/repo/app.ts",
|
||||
relativePath: "app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects workspace-relative paths that escape the workspace", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "../outside.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "src/../../outside.ts", workspaceRoot: "/Users/me/repo" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the relative path for a Windows absolute file inside the workspace", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({
|
||||
path: "C:\\Users\\me\\repo\\src\\app.ts",
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toEqual({
|
||||
absolutePath: "C:/Users/me/repo/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for home-relative paths that cannot be anchored", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the workspace root is not absolute", () => {
|
||||
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "repo" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the resolved file equals the workspace root", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "/Users/me/repo", workspaceRoot: "/Users/me/repo" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export type OpenFileDisposition = "main" | "side";
|
||||
|
||||
export interface WorkspaceFileLocation {
|
||||
@@ -57,3 +59,103 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
if (value === "/" || /^\/+$/.test(value)) {
|
||||
return "/";
|
||||
}
|
||||
if (/^[A-Za-z]:\/+$/.test(value)) {
|
||||
return `${value.slice(0, 2)}/`;
|
||||
}
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function normalizePathSegments(value: string, rejectEscape: boolean): string | null {
|
||||
const segments: string[] = [];
|
||||
for (const segment of value.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (segments.length === 0) {
|
||||
if (rejectEscape) {
|
||||
return null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segments.pop();
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function normalizeAbsolutePath(value: string): string | null {
|
||||
const normalizedInput = trimTrailingSlashes(value);
|
||||
if (!isAbsolutePath(normalizedInput)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
|
||||
if (drivePath) {
|
||||
const normalizedBody = normalizePathSegments(drivePath[2], false);
|
||||
return trimTrailingSlashes(`${drivePath[1]}/${normalizedBody}`);
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
|
||||
const normalizedBody = normalizePathSegments(normalizedInput.replace(/^\/+/, ""), false);
|
||||
return trimTrailingSlashes(`${prefix}${normalizedBody}`);
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value: string): string | null {
|
||||
const normalized = normalizePathSegments(value.replace(/^\/+/, ""), true);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
export interface ResolvedWorkspaceFilePaths {
|
||||
/** Absolute path on the host, suitable for opening in an editor / file manager. */
|
||||
absolutePath: string;
|
||||
/** Path relative to the workspace root, or null when the file lives outside it. */
|
||||
relativePath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a file tab's path (which may be workspace-relative) against the workspace
|
||||
* root. Returns null when an absolute host path cannot be derived — e.g. a `~`-relative
|
||||
* path or a relative path with no workspace root to anchor it.
|
||||
*/
|
||||
export function resolveWorkspaceFilePaths(input: {
|
||||
path: string;
|
||||
workspaceRoot: string;
|
||||
}): ResolvedWorkspaceFilePaths | null {
|
||||
const filePath = input.path.trim().replace(/\\/g, "/");
|
||||
const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||
if (!filePath || !workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolutePath(filePath)) {
|
||||
const normalizedFile = normalizeAbsolutePath(filePath);
|
||||
if (!normalizedFile) {
|
||||
return null;
|
||||
}
|
||||
if (normalizedFile === workspaceRoot) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = normalizedFile.startsWith(`${workspaceRoot}/`)
|
||||
? normalizedFile.slice(workspaceRoot.length + 1)
|
||||
: null;
|
||||
return { absolutePath: normalizedFile, relativePath };
|
||||
}
|
||||
|
||||
if (filePath.startsWith("~")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = normalizeRelativePath(filePath);
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user