Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] e66773b7d0 fix(desktop): find Zed installed as a Flatpak on Linux
Flatpak puts app launchers in its exports/bin directories but does not
add them to PATH, so Zed installed from Flathub never appeared in the
Open in editor list. Search the user and system Flatpak export
directories after PATH on Linux, and look for Zed's Flathub launcher.

Closes #5458
2026-09-29 23:23:14 +02:00
3 changed files with 75 additions and 21 deletions
@@ -1,7 +1,9 @@
import type { SpawnOptions } from "node:child_process"; import type { SpawnOptions } from "node:child_process";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { listAvailableEditorTargets, openEditorTarget } from "./registry.js";
import { createEditorTargetRuntime } from "./runtime.js"; import { createEditorTargetRuntime } from "./runtime.js";
import { zedTarget } from "./targets/zed.js";
interface SpawnRecord { interface SpawnRecord {
command: string; command: string;
@@ -10,6 +12,23 @@ interface SpawnRecord {
unrefed: boolean; unrefed: boolean;
} }
function recordSpawns(records: SpawnRecord[]) {
return (command: string, args: string[], options: SpawnOptions) => {
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
};
}
describe("editor target runtime", () => { describe("editor target runtime", () => {
it("resolves command aliases and safely launches Windows command scripts", async () => { it("resolves command aliases and safely launches Windows command scripts", async () => {
const records: SpawnRecord[] = []; const records: SpawnRecord[] = [];
@@ -20,20 +39,7 @@ describe("editor target runtime", () => {
ELECTRON_RUN_AS_NODE: "1", ELECTRON_RUN_AS_NODE: "1",
}, },
pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd", pathExists: (targetPath) => targetPath === "C:/Program Files/Editors & Tools/bin/code.cmd",
spawn: (command, args, options) => { spawn: recordSpawns(records),
const record = { command, args, options, unrefed: false };
records.push(record);
const child = {
once(event: "error" | "spawn", handler: (error?: Error) => void) {
if (event === "spawn") queueMicrotask(() => handler());
return child;
},
unref() {
record.unrefed = true;
},
};
return child;
},
}); });
const command = runtime.resolveCommand(["missing", "code"]); const command = runtime.resolveCommand(["missing", "code"]);
@@ -57,4 +63,32 @@ describe("editor target runtime", () => {
}, },
]); ]);
}); });
it.each([
["system", "/var/lib/flatpak/exports/bin/dev.zed.Zed"],
["user", "/home/dev/.local/share/flatpak/exports/bin/dev.zed.Zed"],
])("finds and opens Zed from a %s Flatpak install on Linux", async (_scope, flatpakExport) => {
const records: SpawnRecord[] = [];
const existingPaths = new Set([flatpakExport, "/repo", "/repo/src/app.ts"]);
const runtime = createEditorTargetRuntime({
platform: "linux",
env: { PATH: "/usr/local/bin:/usr/bin:/bin" },
homeDirectory: "/home/dev",
pathExists: (targetPath) => existingPaths.has(targetPath),
spawn: recordSpawns(records),
loadIcon: async (fileName) => ({ kind: "image", dataUrl: fileName }),
});
const targets = await listAvailableEditorTargets(runtime, [zedTarget]);
expect(targets.map((target) => target.id)).toEqual(["zed"]);
await openEditorTarget(
{ editorId: "zed", workspacePath: "/repo", filePath: "/repo/src/app.ts", line: 3 },
runtime,
[zedTarget],
);
expect(records.map(({ command, args }) => ({ command, args }))).toEqual([
{ command: flatpakExport, args: ["/repo", "/repo/src/app.ts:3"] },
]);
});
}); });
@@ -56,10 +56,28 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
} }
// Flatpak exports app launchers here, but does not put these directories on PATH.
function flatpakExportDirectories(env: NodeJS.ProcessEnv, homeDirectory: string): string[] {
const userDataDirectory = env.XDG_DATA_HOME || `${homeDirectory}/.local/share`;
return [`${userDataDirectory}/flatpak/exports/bin`, "/var/lib/flatpak/exports/bin"];
}
function commandSearchDirectories(input: {
env: NodeJS.ProcessEnv;
platform: NodeJS.Platform;
homeDirectory: string;
}): string[] {
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? "";
const pathDelimiter = input.platform === "win32" ? ";" : ":";
const directories = pathValue.split(pathDelimiter).filter(Boolean);
if (input.platform !== "linux") return directories;
return [...directories, ...flatpakExportDirectories(input.env, input.homeDirectory)];
}
function resolveExecutable( function resolveExecutable(
commands: readonly string[], commands: readonly string[],
input: { input: {
env: NodeJS.ProcessEnv; searchDirectories: readonly string[];
pathExists: (path: string) => boolean; pathExists: (path: string) => boolean;
platform: NodeJS.Platform; platform: NodeJS.Platform;
}, },
@@ -69,10 +87,7 @@ function resolveExecutable(
return command; return command;
} }
const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? ""; for (const directory of input.searchDirectories) {
const pathDelimiter = input.platform === "win32" ? ";" : ":";
for (const directory of pathValue.split(pathDelimiter)) {
if (!directory) continue;
const candidate = `${directory}/${command}`; const candidate = `${directory}/${command}`;
if (input.platform !== "win32") { if (input.platform !== "win32") {
if (input.pathExists(candidate)) return candidate; if (input.pathExists(candidate)) return candidate;
@@ -140,7 +155,12 @@ export function createEditorTargetRuntime(
env, env,
pathExists, pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform), isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }), resolveCommand: (commands) =>
resolveExecutable(commands, {
searchDirectories: commandSearchDirectories({ env, platform, homeDirectory }),
pathExists,
platform,
}),
async spawnDetached({ command, args }) { async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform); const commandScript = isWindowsCommandScript(command, platform);
const launchCommand = commandScript ? escapeWindowsCmdValue(command) : command; const launchCommand = commandScript ? escapeWindowsCmdValue(command) : command;
@@ -1,6 +1,6 @@
import type { EditorTarget, EditorTargetLaunchInput } from "../target.js"; import type { EditorTarget, EditorTargetLaunchInput } from "../target.js";
const COMMANDS = ["zed", "zeditor"] as const; const COMMANDS = ["zed", "zeditor", "dev.zed.Zed"] as const;
function location(input: EditorTargetLaunchInput): string { function location(input: EditorTargetLaunchInput): string {
if (!input.line) return input.filePath!; if (!input.line) return input.filePath!;