Skip to content
Merged
167 changes: 129 additions & 38 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Latch from "effect/Latch";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
Expand All @@ -27,6 +29,7 @@ import * as ExternalLauncher from "./externalLauncher.ts";
const windowsHost = HostProcessPlatform.defaultValue() === "win32";

interface MockSpawnResult {
readonly spawnError?: PlatformError.PlatformError;
readonly exitCode?: number;
readonly stdout?: string;
/** Never deliver an exit code, like a child wedged on a broken desktop session. */
Expand Down Expand Up @@ -68,15 +71,17 @@ const testLayer = (input: {
const spawnerLayer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
Effect.sync(() => {
Effect.gen(function* () {
assert.equal(ChildProcess.isStandardCommand(command), true);
if (!ChildProcess.isStandardCommand(command)) {
throw new Error("Expected a standard command");
}
input.onSpawn?.(command);
const result = input.spawnResult?.(command);
if (result?.spawnError) return yield* result.spawnError;
return makeMockDetachedHandle({
...(input.onUnref === undefined ? {} : { onUnref: input.onUnref }),
...input.spawnResult?.(command),
...result,
});
}),
),
Expand Down Expand Up @@ -677,44 +682,130 @@ it.effect.skipIf(windowsHost)(
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect.skipIf(windowsHost)("reveals by opening the containing directory on Linux", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
for (const name of ["xdg-open", "xdg-mime"]) {
const filePath = path.join(binDir, name);
yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
yield* fileSystem.chmod(filePath, 0o755);
}
for (const wsl of [false, true]) {
it.effect.skipIf(windowsHost)(
"selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"),
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
for (const name of ["xdg-open", "xdg-mime", "gdbus", ...(wsl ? ["explorer.exe"] : [])]) {
const filePath = path.join(binDir, name);
yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
yield* fileSystem.chmod(filePath, 0o755);
}
const target = "/workspace/media/a 'quoted' \"clip\" #100% ? é\\name.mp4";
const spawnedCommands: ChildProcess.StandardCommand[] = [];
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({ editor: "file-manager", cwd: target, reveal: true });
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: {
PATH: binDir,
WAYLAND_DISPLAY: "wayland-1",
...(wsl
? { WSL_DISTRO_NAME: "Ubuntu-24.04", WSL_INTEROP: "/run/WSL/1_interop" }
: {}),
},
onSpawn: (command) => spawnedCommands.push(command),
spawnResult: (command) =>
command.command === "xdg-mime"
? { stdout: "com.thisisgm.flea.desktop\n" }
: undefined,
}),
),
);

const spawnedCommands: ChildProcess.StandardCommand[] = [];
yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({
editor: "file-manager",
cwd: "/workspace/media/linux-mini-v2.mp4",
reveal: true,
});
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir, DISPLAY: ":0" },
onSpawn: (command) => {
spawnedCommands.push(command);
},
spawnResult: (command) =>
command.command === "xdg-mime" ? { stdout: "org.gnome.Nautilus.desktop\n" } : undefined,
}),
),
);
const reveal = spawnedCommands.find((command) => command.command === "gdbus");
assert.ok(reveal);
assert.deepEqual(reveal.args, [
"call",
"--session",
"--dest",
"org.freedesktop.FileManager1",
"--object-path",
"/org/freedesktop/FileManager1",
"--method",
"org.freedesktop.FileManager1.ShowItems",
"[\"file:///workspace/media/a%20'quoted'%20%22clip%22%20%23100%25%20%3F%20%C3%A9%5Cname.mp4\"]",
"",
]);
assert.isUndefined(spawnedCommands.find((command) => command.command === "xdg-open"));
assert.isUndefined(spawnedCommands.find((command) => command.command === "explorer.exe"));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
}

const spawned = spawnedCommands.find((command) => command.command === "xdg-open");
assert.ok(spawned);
assert.deepEqual(spawned.args, ["/workspace/media"]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
for (const scenario of ["missing", "rejected", "spawn-error", "stalled"] as const) {
it.effect.skipIf(windowsHost)("opens the containing directory when D-Bus is " + scenario, () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
for (const name of ["xdg-open", "xdg-mime", ...(scenario === "missing" ? [] : ["gdbus"])]) {
const filePath = path.join(binDir, name);
yield* fileSystem.writeFileString(filePath, "#!/bin/sh\n");
yield* fileSystem.chmod(filePath, 0o755);
}

const spawnedCommands: ChildProcess.StandardCommand[] = [];
const started = yield* Latch.make();
const launch = Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
yield* launcher.launchEditor({
editor: "file-manager",
cwd: "/workspace/media/linux-mini-v2.mp4",
reveal: true,
});
}).pipe(
Effect.provide(
testLayer({
platform: "linux",
env: { PATH: binDir, DISPLAY: ":0" },
onSpawn: (command) => {
spawnedCommands.push(command);
if (command.command === "gdbus") started.openUnsafe();
},
spawnResult: (command) => {
if (command.command === "xdg-mime") return { stdout: "org.gnome.Nautilus.desktop\n" };
if (command.command !== "gdbus") return undefined;
if (scenario === "spawn-error") {
return {
spawnError: new PlatformError.PlatformError(
new PlatformError.SystemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
pathOrDescriptor: "gdbus",
}),
),
};
}
return { exitCode: 1, stall: scenario === "stalled" };
},
}),
),
);
if (scenario === "stalled") {
const fiber = yield* launch.pipe(Effect.forkChild);
yield* started.await;
yield* TestClock.adjust("2 seconds");
yield* Fiber.join(fiber);
} else {
yield* launch;
}

const spawned = spawnedCommands.find((command) => command.command === "xdg-open");
assert.ok(spawned);
assert.deepEqual(spawned.args, ["/workspace/media"]);
assert.equal(spawnedCommands.filter((command) => command.command === "xdg-open").length, 1);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
}

it.effect.skipIf(windowsHost)(
"does not advertise a Linux file manager without a graphical session",
Expand Down
58 changes: 53 additions & 5 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,12 +670,60 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch
};
}

// Linux file managers have no portable "select this file" flag, so open
// the containing directory instead.
// Folder fallback when the Linux file manager cannot select items over D-Bus.
const path = yield* Path.Path;
return { editor: "file-manager", target, command, args: [path.dirname(target)] };
});

const tryRevealLinuxFile = Effect.fn("externalLauncher.tryRevealLinuxFile")(
function* (target: string) {
const env = yield* readCommandLookupEnv;
if (!(yield* isCommandAvailable("gdbus", { env }))) return false;

const path = yield* Path.Path;
const uri = yield* path.toFileUrl(target);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const exitCode = yield* spawner.exitCode(
ChildProcess.make(
"gdbus",
[
"call",
"--session",
"--dest",
"org.freedesktop.FileManager1",
"--object-path",
"/org/freedesktop/FileManager1",
"--method",
"org.freedesktop.FileManager1.ShowItems",
// File URLs already escape quotes and backslashes for this GVariant string.
`["${uri.href}"]`,
"",
],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
),
);
return exitCode === 0;
},
// A missing service or stalled desktop session must still open the folder.
Effect.timeout("2 seconds"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Effect.orElseSucceed(() => false),
);

const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* (
input: LaunchEditorInput,
) {
const launch = yield* resolveEditorLaunch(input);
if (
input.editor === "file-manager" &&
input.reveal === true &&
launch.command === "xdg-open" &&
(yield* tryRevealLinuxFile(input.cwd))
) {
return;
}
yield* launchEditorProcess(launch);
});

const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
launch: ProcessLaunch,
onError: (cause: unknown) => ExternalLauncherError,
Expand Down Expand Up @@ -793,9 +841,9 @@ export const make = Effect.gen(function* () {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
launchEditor: (input) =>
provideCommandResolutionServices(
Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess),
).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
provideCommandResolutionServices(launchEditor(input)).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
});
});

Expand Down
4 changes: 4 additions & 0 deletions docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ File links refer to the environment's machine, including when you connect remote
Previews use the original file, even outside the workspace. Moving or deleting it
can break the preview, so save a copy if you need to keep it.

On Linux with a local environment, right-click a file link and choose **Reveal in
Files** to select it in your file manager. If your desktop cannot select the file,
T3 Code opens its containing folder instead.

## Files outside the workspace

Follow an agent's file link to read a report or other file outside the workspace.
Expand Down
Loading