Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import { ChildProcessSpawner } from "effect/process";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as HttpClient from "effect/http/HttpClient";
Expand Down Expand Up @@ -227,6 +228,7 @@ it.effect.each([false, true])(
Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {}),
Layer.mock(ElectronMenu.ElectronMenu, {}),
Layer.mock(ElectronShell.ElectronShell, {}),
Layer.mock(ChildProcessSpawner.ChildProcessSpawner, {}),
Layer.mock(ElectronTheme.ElectronTheme, {}),
Layer.mock(ElectronWindow.ElectronWindow, {}),
Layer.mock(MacPermissions.MacPermissions, {}),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
openExternal,
readMedia,
openSystemSettings,
revealItemInFolder,
checkSystemPermission,
pasteAsText,
probeRemoteEditors,
Expand Down Expand Up @@ -152,6 +153,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(receiveProviderAuthCallback);
yield* ipc.handle(cancelProviderAuthCallback);
yield* ipc.handle(openSystemSettings);
yield* ipc.handle(revealItemInFolder);
yield* ipc.handle(checkSystemPermission);
yield* ipc.handle(pasteAsText);
yield* ipc.handle(revealWindow);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const READ_MEDIA_CHANNEL = "desktop:read-media";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const REVEAL_ITEM_IN_FOLDER_CHANNEL = "desktop:reveal-item-in-folder";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
Expand Down
85 changes: 84 additions & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,29 @@ import * as Path from "effect/Path";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/process";
import { vi } from "vite-plus/test";
import type * as Electron from "electron";

const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({
const { focusedWebContents, ownerWindow, showItemInFolder } = vi.hoisted(() => ({
focusedWebContents: vi.fn(),
ownerWindow: vi.fn(),
showItemInFolder: vi.fn(),
}));
vi.mock("electron", () => ({
webContents: { getFocusedWebContents: focusedWebContents },
BrowserWindow: { fromWebContents: ownerWindow },
shell: { showItemInFolder },
}));

import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts";
import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as IpcChannels from "../channels.ts";
Expand All @@ -35,6 +41,7 @@ import {
pickProjectFavicon,
probeRemoteEditors,
readMedia,
revealItemInFolder,
syncHtmlFullscreen,
} from "./window.ts";

Expand Down Expand Up @@ -285,6 +292,82 @@ describe("readMedia", () => {
);
});

describe("revealItemInFolder", () => {
const STOCK_FOLDER_VERB =
"\r\nHKEY_CLASSES_ROOT\\Directory\\shell\r\n (Default) REG_SZ none\r\n\r\n";

const reveal = (
path: string,
input: {
readonly platform?: NodeJS.Platform;
readonly stdout?: string;
readonly exitCode?: number;
},
) =>
Effect.sync(() => showItemInFolder.mockClear()).pipe(
Effect.andThen(revealItemInFolder.handler(path)),
Effect.provide(
Layer.mergeAll(
Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
platform: input.platform ?? "win32",
} as DesktopEnvironment.DesktopEnvironment["Service"]),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout:
input.stdout === undefined
? Stream.empty
: Stream.make(new TextEncoder().encode(input.stdout)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
),
),
),
),
),
);

it.effect("reveals a local file natively on stock Windows", () =>
Effect.gen(function* () {
const revealed = yield* reveal("C:/Users/t3/my project/a,b.ts", {
stdout: STOCK_FOLDER_VERB,
});

assert.isTrue(revealed);
assert.deepEqual(showItemInFolder.mock.calls, [["C:\\Users\\t3\\my project\\a,b.ts"]]);
}),
);

it.effect.each([
["on macOS", "/Users/t3/a.ts", { platform: "darwin", stdout: STOCK_FOLDER_VERB }],
["for a WSL path", "\\\\wsl.localhost\\Ubuntu\\a.ts", { stdout: STOCK_FOLDER_VERB }],
[
"with a replacement file manager",
"C:\\a.ts",
{ stdout: " (Default) REG_SZ open\r\n" },
],
["when the registry lookup fails", "C:\\a.ts", { exitCode: 1 }],
] as const)("leaves the reveal to the server $0", ([, path, input]) =>
Effect.gen(function* () {
const revealed = yield* reveal(path, input);

assert.isFalse(revealed);
assert.equal(showItemInFolder.mock.calls.length, 0);
}),
);
});

describe("pickProjectFavicon", () => {
const pickerLayer = (
pickFiles: () => Effect.Effect<Array<string>>,
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import * as Path from "effect/Path";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
Expand Down Expand Up @@ -371,6 +373,45 @@ export const readMedia = DesktopIpc.makeIpcMethod({
}),
});

const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/;

// Stock Windows answers "none"; anything else may be a replacement file manager, which only the
// server reveal honors. spawner.string takes about 600 ms longer on Windows, so read stdout here.
const hasStockWindowsFolderVerb = Effect.fn("desktop.ipc.window.hasStockWindowsFolderVerb")(
function* () {
const env = yield* HostProcess.Environment;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const handle = yield* spawner.spawn(
ChildProcess.make(
`${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\reg.exe`,
["query", "HKCR\\Directory\\shell", "/ve"],
{ stdin: "ignore", stderr: "ignore" },
),
);
const [stdout, exitCode] = yield* Effect.all(
[handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
{ concurrency: "unbounded" },
);
return exitCode === 0 && /REG_SZ\s+none\s*$/im.test(stdout);
},
Effect.scoped,
Effect.timeout("2 seconds"),
Effect.orElseSucceed(() => false),
);

export const revealItemInFolder = DesktopIpc.makeIpcMethod({
channel: IpcChannels.REVEAL_ITEM_IN_FOLDER_CHANNEL,
payload: Schema.String,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.revealItemInFolder")(function* (path) {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
if (environment.platform !== "win32" || !WINDOWS_DRIVE_PATH.test(path)) return false;
if (!(yield* hasStockWindowsFolderVerb())) return false;
Electron.shell.showItemInFolder(path.replaceAll("/", "\\"));
return true;
}),
});

export const openSystemSettings = DesktopIpc.makeIpcMethod({
channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL,
payload: SystemSettingsPaneSchema,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),
openSystemSettings: (pane: string) =>
ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
revealItemInFolder: (path: string) =>
ipcRenderer.invoke(IpcChannels.REVEAL_ITEM_IN_FOLDER_CHANNEL, path),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
onMenuAction: (listener) => {
Expand Down
18 changes: 13 additions & 5 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { cn } from "../lib/utils";
import { resolveRemoteOpenState, useRemoteOpenResolution } from "../remoteOpen";
import {
resolveRemoteOpenState,
revealInDesktopShell,
useRemoteOpenResolution,
} from "../remoteOpen";
import { useRightPanelStore } from "../rightPanelStore";
import { readThreadShell, useProjects } from "../state/entities";
import { serverEnvironment } from "../state/server";
Expand Down Expand Up @@ -650,10 +654,14 @@ function useChatMarkdownState({
),
);
}
return openInEditor({
environmentId,
input: { cwd: filePath, editor: "file-manager", reveal: true },
});
return revealInDesktopShell(environmentId, filePath).then((revealed) =>
revealed
? AsyncResult.success<void, never>(undefined)
: openInEditor({
environmentId,
input: { cwd: filePath, editor: "file-manager", reveal: true },
}),
);
};
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const handleCopy = (event: ReactClipboardEvent<HTMLElement>) => {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { usePreferredEditor } from "../../editorPreferences";
import { editorLabelForPlatform } from "../../editorLabels";
import {
openRemoteEditorUrl,
revealInDesktopShell,
useRemoteCapableEditors,
useRemoteOpenHint,
useRemoteOpenState,
Expand Down Expand Up @@ -264,12 +265,17 @@ export const OpenInPicker = memo(function OpenInPicker({
return true;
}
if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
const reveal = revealFileManager && editor === "file-manager";
if (reveal && (await revealInDesktopShell(environmentId, openInCwd))) {
setPreferredEditor(editor);
return true;
}
const result = openInEditorMutation({
environmentId,
input: {
cwd: openInCwd,
editor,
...(revealFileManager && editor === "file-manager" ? { reveal: true } : {}),
...(reveal ? { reveal: true } : {}),
},
});
setPreferredEditor(editor);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/fileContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
revealInFileExplorerLabelForOs,
} from "~/components/preview/fileExplorerLabel";
import { readLocalApi } from "./localApi";
import { revealInDesktopShell } from "./remoteOpen";
import { serverEnvironment } from "./state/server";
import { shellEnvironment } from "./state/shell";
import { useAtomCommand } from "./state/use-atom-command";
Expand Down Expand Up @@ -119,6 +120,7 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) {
: (action.slice("editor:".length) as EditorId);
if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return;

if (reveal && (await revealInDesktopShell(environmentId, absolutePath))) return;
const result = await openInEditor({
environmentId,
input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) },
Expand Down
62 changes: 60 additions & 2 deletions apps/web/src/remoteOpen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,33 @@ import {
SshConnectionTarget,
} from "@t3tools/client-runtime/connection";
import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { Option } from "effect";
import { Atom } from "effect/reactivity";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import { resolveRemoteOpenState } from "./remoteOpen";
const { readEnvironmentScope, presentations } = vi.hoisted(() => ({
readEnvironmentScope: vi.fn(),
presentations: new Map<string, unknown>(),
}));
vi.mock(import("./state/session"), async (importOriginal) => ({
...(await importOriginal()),
readEnvironmentScope,
}));
vi.mock(import("./state/presentation"), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
environmentPresentations: {
...actual.environmentPresentations,
presentationAtom: ((id: EnvironmentId) =>
Atom.make(
presentations.get(id) ?? null,
)) as typeof actual.environmentPresentations.presentationAtom,
},
};
});

import { resolveRemoteOpenState, revealInDesktopShell } from "./remoteOpen";

const environmentId = EnvironmentId.make("environment-1");

Expand All @@ -23,6 +47,40 @@ const TAILSCALE_TARGETS = [
{ kind: "mdns", host: "sol.local" },
] as const;

describe("revealInDesktopShell", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const local = {
entry: { target: primaryTarget("http://127.0.0.1:8000"), profile: Option.none() },
};
const remote = {
entry: {
target: new RelayConnectionTarget({ environmentId, label: "sol" }),
profile: Option.none(),
},
serverConfig: { remoteOpenTargets: TAILSCALE_TARGETS },
};

it.each([
["reveals natively with the operate scope", local, true, true, true, 1],
["leaves a connection without the operate scope to the server", local, false, true, false, 0],
["reports a declined reveal so the server can try", local, true, false, false, 1],
["leaves a connection that has not resolved yet to the server", null, true, true, false, 0],
["leaves a remote connection to the server", remote, true, true, false, 0],
])("%s", async (name, presentation, hasScope, bridgeResult, expected, bridgeCalls) => {
const id = EnvironmentId.make(`reveal-${name}`);
presentations.set(id, presentation);
readEnvironmentScope.mockReturnValue(hasScope);
const revealItemInFolder = vi.fn(async () => bridgeResult);
vi.stubGlobal("window", { desktopBridge: { revealItemInFolder } });

await expect(revealInDesktopShell(id, "C:\\repo\\a.ts")).resolves.toBe(expected);
expect(revealItemInFolder).toHaveBeenCalledTimes(bridgeCalls);
});
});

describe("resolveRemoteOpenState", () => {
it("keeps exec behavior for a loopback primary target", () => {
expect(
Expand Down
Loading
Loading