Skip to content
Merged
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/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const makeDesktopClerkLayer = (
openExternal: () => Effect.succeed(true),
openSystemSettings: () => Effect.succeed(false),
copyText: () => Effect.void,
showItemInFolder: () => Effect.void,
},
) => {
const environment = DesktopEnvironment.DesktopEnvironment.of({
Expand Down Expand Up @@ -308,6 +309,7 @@ for (const entry of ["startup", "open-url"] as const) {
}),
openSystemSettings: () => Effect.succeed(false),
copyText: () => Effect.void,
showItemInFolder: () => Effect.void,
});
const listeners = new Map<string, (...args: unknown[]) => void>();
const electronApp = {
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export class ElectronDialogPickFilesError extends Schema.TaggedError<ElectronDia
}
}

export class ElectronDialogSaveFileError extends Schema.TaggedError<ElectronDialogSaveFileError>()(
"ElectronDialogSaveFileError",
{
ownerWindowId: Schema.NullOr(Schema.Number),
cause: Schema.Defect(),
},
) {
override get message(): string {
const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
return `Failed to open the Electron save dialog for ${owner}.`;
}
}

export class ElectronDialogShowMessageBoxError extends Schema.TaggedError<ElectronDialogShowMessageBoxError>()(
"ElectronDialogShowMessageBoxError",
{
Expand Down Expand Up @@ -69,6 +82,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedError<Electron
export const ElectronDialogError = Schema.Union([
ElectronDialogPickFolderError,
ElectronDialogPickFilesError,
ElectronDialogSaveFileError,
ElectronDialogShowMessageBoxError,
ElectronDialogShowErrorBoxError,
]);
Expand All @@ -86,6 +100,12 @@ export interface ElectronDialogPickFilesInput {
readonly multiple: boolean;
}

export interface ElectronDialogSaveFileInput {
readonly owner: Option.Option<Electron.BrowserWindow>;
readonly defaultPath: string;
readonly filters: readonly Electron.FileFilter[];
}

export class ElectronDialog extends Context.Service<
ElectronDialog,
{
Expand All @@ -95,6 +115,9 @@ export class ElectronDialog extends Context.Service<
readonly pickFiles: (
input: ElectronDialogPickFilesInput,
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
readonly saveFile: (
input: ElectronDialogSaveFileInput,
) => Effect.Effect<Option.Option<string>, ElectronDialogSaveFileError>;
readonly showMessageBox: (
options: Electron.MessageBoxOptions,
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
Expand Down Expand Up @@ -164,6 +187,29 @@ export const make = ElectronDialog.of({
});
return result.canceled ? [] : result.filePaths;
}),
saveFile: Effect.fn("desktop.electron.dialog.saveFile")(function* (input) {
const saveDialogOptions: Electron.SaveDialogOptions = {
defaultPath: input.defaultPath,
filters: [...input.filters],
properties: ["createDirectory", "showOverwriteConfirmation"],
};
const result = yield* Effect.tryPromise({
try: () =>
Option.match(input.owner, {
onNone: () => Electron.dialog.showSaveDialog(saveDialogOptions),
onSome: (owner) => Electron.dialog.showSaveDialog(owner, saveDialogOptions),
}),
catch: (cause) =>
new ElectronDialogSaveFileError({
ownerWindowId: Option.match(input.owner, {
onNone: () => null,
onSome: (owner) => owner.id,
}),
cause,
}),
});
return result.canceled ? Option.none() : Option.fromNullishOr(result.filePath || undefined);
}),
showMessageBox: (options) =>
Effect.tryPromise({
try: () => Electron.dialog.showMessageBox(options),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class ElectronShell extends Context.Service<
/** Opens a known System Settings pane by identifier, not by URL. */
readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect<boolean>;
readonly copyText: (text: string) => Effect.Effect<void>;
readonly showItemInFolder: (path: string) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronShell") {}

Expand All @@ -86,6 +87,7 @@ export const make = ElectronShell.of({
),
copyText: (text) =>
Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)),
showItemInFolder: (path) => Effect.sync(() => Electron.shell.showItemInFolder(path)),
});

export const layer = Layer.succeed(ElectronShell, make);
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ import {
pickFolder,
pickProjectFavicon,
pickThemeFiles,
saveTextFile,
setTheme,
showContextMenu,
showSavedFileInFolder,
} from "./methods/window.ts";
import {
acknowledgeSnapShot,
Expand Down Expand Up @@ -129,6 +131,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(pickFolder);
yield* ipc.handle(pickProjectFavicon);
yield* ipc.handle(pickThemeFiles);
yield* ipc.handle(saveTextFile);
yield* ipc.handle(showSavedFileInFolder);
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SAVE_TEXT_FILE_CHANNEL = "desktop:save-text-file";
export const SHOW_SAVED_FILE_IN_FOLDER_CHANNEL = "desktop:show-saved-file-in-folder";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock("electron", () => ({
import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronShell from "../../electron/ElectronShell.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts";
Expand All @@ -31,6 +32,8 @@ import {
pasteAsText,
pickProjectFavicon,
probeRemoteEditors,
saveTextFile,
showSavedFileInFolder,
} from "./window.ts";

const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
Expand Down Expand Up @@ -266,6 +269,62 @@ describe("pickProjectFavicon", () => {
);
});

describe("saveTextFile", () => {
const saveLayer = (
saveFile: () => Effect.Effect<Option.Option<string>>,
showItemInFolder: (path: string) => Effect.Effect<void>,
) =>
Layer.mergeAll(
Layer.mock(ElectronDialog.ElectronDialog)({ saveFile }),
Layer.mock(ElectronShell.ElectronShell)({ showItemInFolder }),
Layer.mock(ElectronWindow.ElectronWindow)({ focusedMainOrFirst: Effect.succeedNone }),
NodeServices.layer,
);

it.effect("writes the file the user picked and can reveal only that file", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-save-text-" });
const target = path.join(directory, "chosen.md");
const revealed: string[] = [];
const layer = saveLayer(
() => Effect.succeedSome(target),
(filePath) => Effect.sync(() => void revealed.push(filePath)),
);

const saved = yield* saveTextFile
.handler({ defaultFileName: "thread.md", contents: "# Thread\n" })
.pipe(Effect.provide(layer));
yield* showSavedFileInFolder
.handler(path.join(directory, "other.md"))
.pipe(Effect.provide(layer));
yield* showSavedFileInFolder.handler(target).pipe(Effect.provide(layer));

assert.strictEqual(saved, target);
assert.strictEqual(yield* fs.readFileString(target), "# Thread\n");
assert.deepEqual(revealed, [target]);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("writes nothing when the dialog is cancelled", () =>
Effect.gen(function* () {
const saved = yield* saveTextFile
.handler({ defaultFileName: "thread.md", contents: "# Thread\n" })
.pipe(
Effect.provide(
saveLayer(
() => Effect.succeedNone,
() => Effect.void,
),
),
);

assert.strictEqual(saved, null);
}),
);
});

it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
"finds remote editors installed without PATH launchers",
() =>
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PickedThemeFileSchema,
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
SaveTextFileInputSchema,
REMOTE_CAPABLE_EDITOR_IDS,
SystemSettingsPaneSchema,
type DesktopEnvironmentBootstrap,
Expand Down Expand Up @@ -420,6 +421,45 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({
}),
});

// Paths this process wrote through `saveTextFile`, so the renderer can only
// reveal files the user just saved rather than probe arbitrary paths.
const savedTextFilePaths = new Set<string>();

export const saveTextFile = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SAVE_TEXT_FILE_CHANNEL,
payload: SaveTextFileInputSchema,
result: Schema.NullOr(Schema.String),
handler: Effect.fn("desktop.ipc.window.saveTextFile")(function* (input) {
const dialog = yield* ElectronDialog.ElectronDialog;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const extension = path.extname(input.defaultFileName).slice(1);
const selectedPath = yield* dialog.saveFile({
owner: yield* electronWindow.focusedMainOrFirst,
defaultPath: path.join(NodeOS.homedir(), "Downloads", path.basename(input.defaultFileName)),
filters: extension === "md" ? [{ name: "Markdown", extensions: ["md"] }] : [],
});
if (Option.isNone(selectedPath)) {
return null;
}
yield* fileSystem.writeFileString(selectedPath.value, input.contents);
savedTextFilePaths.add(selectedPath.value);
return selectedPath.value;
}),
});

export const showSavedFileInFolder = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SHOW_SAVED_FILE_IN_FOLDER_CHANNEL,
payload: Schema.String,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.showSavedFileInFolder")(function* (filePath) {
if (!savedTextFilePaths.has(filePath)) return;
const shell = yield* ElectronShell.ElectronShell;
yield* shell.showItemInFolder(filePath);
}),
});

export const checkSystemPermission = DesktopIpc.makeIpcMethod({
channel: IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL,
payload: SystemSettingsPaneSchema,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
pickProjectFavicon: (initialPath) =>
ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath),
pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
saveTextFile: (input) => ipcRenderer.invoke(IpcChannels.SAVE_TEXT_FILE_CHANNEL, input),
showSavedFileInFolder: (path) =>
ipcRenderer.invoke(IpcChannels.SHOW_SAVED_FILE_IN_FOLDER_CHANNEL, path),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) =>
ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.succeedNone,
pickFiles: () => Effect.succeed([]),
saveFile: () => Effect.succeedNone,
showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
showErrorBox: () => Effect.void,
} satisfies ElectronDialog.ElectronDialog["Service"]);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ function makeTestLayer(input: {
Effect.sync(() => {
input.copiedTexts?.push(text);
}),
showItemInFolder: () => Effect.void,
} satisfies ElectronShell.ElectronShell["Service"]),
electronThemeLayer,
electronWindowLayer,
Expand Down Expand Up @@ -410,6 +411,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
openExternal: () => Effect.succeed(true),
openSystemSettings: () => Effect.succeed(true),
copyText: () => Effect.void,
showItemInFolder: () => Effect.void,
} satisfies ElectronShell.ElectronShell["Service"]),
electronThemeLayer,
Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape),
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
ArrowLeftIcon,
ChartNoAxesColumnIcon,
CornerLeftUpIcon,
DownloadIcon,
FileSearchIcon,
FolderIcon,
FolderPlusIcon,
Expand Down Expand Up @@ -173,6 +174,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectFavicon } from "./ProjectFavicon";
import { ProjectFilePicker } from "./files/ProjectFilePicker";
import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
import { openTranscriptExportDialog } from "./TranscriptExportDialog";
import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
Expand Down Expand Up @@ -1828,6 +1830,20 @@ function OpenCommandPaletteDialog(props: {
});
}

if (activeThread !== null) {
const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id);
actionItems.push({
kind: "action",
value: "action:export-transcript",
searchTerms: ["export", "transcript", "markdown", "save", "copy", "conversation"],
title: "Export transcript",
icon: <DownloadIcon className={ITEM_ICON_CLASS} />,
run: async () => {
openTranscriptExportDialog(threadRef);
},
});
}

if (
activeThread !== null &&
threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported"
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ import {
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
import { openTranscriptExportDialog } from "./TranscriptExportDialog";
const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
created_at: "Created at",
Expand Down Expand Up @@ -2276,6 +2277,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
{ id: "mark-unread", label: "Mark unread" },
{ id: "copy-path", label: "Copy Path" },
{ id: "copy-thread-id", label: "Copy Thread ID" },
{ id: "export-transcript", label: "Export transcript…" },
{ id: "project-settings", label: "Project settings" },
{ id: "delete", label: "Delete", destructive: true, icon: "trash" },
],
Expand Down Expand Up @@ -2348,6 +2350,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
copyThreadIdToClipboard(thread.id, { threadId: thread.id });
return;
}
if (clicked === "export-transcript") {
openTranscriptExportDialog(threadRef);
return;
}
if (clicked !== "delete") return;
if (appSettingsConfirmThreadDelete) {
const confirmed = await api.dialogs.confirm(
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge";
import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import { openTranscriptExportDialog } from "./TranscriptExportDialog";
import {
animateSidebarLayoutChanges,
applySidebarThreadDrop,
Expand Down Expand Up @@ -4701,6 +4702,9 @@ export default function Sidebar() {
case "copy-thread-id":
copyThreadIdToClipboard(thread.id, { threadId: thread.id });
return;
case "export-transcript":
openTranscriptExportDialog(threadRef);
return;
case "archive": {
if (confirmThreadArchive) {
const confirmed = await settlePromise(() =>
Expand Down
Loading