Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ import React, {
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { openInPreferredEditor } from "../editorPreferences";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
import { readNativeApi } from "../nativeApi";
import { preferredTerminalEditor } from "../terminal-links";

class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
Expand Down Expand Up @@ -252,7 +252,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
event.stopPropagation();
const api = readNativeApi();
if (api) {
void api.shell.openInEditor(targetPath, preferredTerminalEditor());
void openInPreferredEditor(api, targetPath);
} else {
console.warn("Native API not found. Unable to open file in editor.");
}
Expand Down
28 changes: 18 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
type ApprovalRequestId,
DEFAULT_MODEL_BY_PROVIDER,
EDITORS,
type EditorId,
type KeybindingCommand,
type CodexReasoningEffort,
Expand Down Expand Up @@ -29,6 +28,11 @@ import {
normalizeModelSlug,
resolveModelSlugForProvider,
} from "@t3tools/shared/model";
import {
readStoredPreferredEditor,
resolvePreferredEditor,
writeStoredPreferredEditor,
} from "../editorPreferences";
import {
memo,
useCallback,
Expand Down Expand Up @@ -247,7 +251,6 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}

const LAST_EDITOR_KEY = "t3code:last-editor";
const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
Expand Down Expand Up @@ -6044,10 +6047,7 @@ const OpenInPicker = memo(function OpenInPicker({
availableEditors: ReadonlyArray<EditorId>;
openInCwd: string | null;
}) {
const [lastEditor, setLastEditor] = useState<EditorId>(() => {
const stored = localStorage.getItem(LAST_EDITOR_KEY);
return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
});
const [lastEditor, setLastEditor] = useState<EditorId | null>(() => readStoredPreferredEditor());

const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>(
() => [
Expand Down Expand Up @@ -6083,19 +6083,27 @@ const OpenInPicker = memo(function OpenInPicker({
[allOptions, availableEditors],
);

const effectiveEditor = options.some((option) => option.value === lastEditor)
? lastEditor
: (options[0]?.value ?? null);
const effectiveEditor =
lastEditor && options.some((option) => option.value === lastEditor)
? lastEditor
: resolvePreferredEditor(availableEditors);
const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;

useEffect(() => {
if (!effectiveEditor) return;
const stored = readStoredPreferredEditor();
if (stored === effectiveEditor) return;
writeStoredPreferredEditor(effectiveEditor);
}, [effectiveEditor]);

@juliusmarminge juliusmarminge Mar 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time to add useLocalStorage I guess 🙃 (or zustand with persist middleware like we do in other places but feels overkill for a single value here)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want me to add it in this PR?


const openInEditor = useCallback(
(editorId: EditorId | null) => {
const api = readNativeApi();
if (!api || !openInCwd) return;
const editor = editorId ?? effectiveEditor;
if (!editor) return;
void api.shell.openInEditor(openInCwd, editor);
localStorage.setItem(LAST_EDITOR_KEY, editor);
writeStoredPreferredEditor(editor);
setLastEditor(editor);
},
[effectiveEditor, openInCwd, setLastEditor],
Expand Down
14 changes: 4 additions & 10 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,13 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
import {
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { openInPreferredEditor } from "../editorPreferences";
import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
import { cn } from "~/lib/utils";
import { readNativeApi } from "../nativeApi";
import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links";
import { resolvePathLinkTarget } from "../terminal-links";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
Expand Down Expand Up @@ -311,7 +305,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const api = readNativeApi();
if (!api) return;
const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;
void api.shell.openInEditor(targetPath, preferredTerminalEditor()).catch((error) => {
void openInPreferredEditor(api, targetPath).catch((error) => {
console.warn("Failed to open diff file in editor.", error);
});
},
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Textarea } from "~/components/ui/textarea";
import { toastManager } from "~/components/ui/toast";
import { openInPreferredEditor } from "~/editorPreferences";
import {
gitBranchesQueryOptions,
gitInitMutationOptions,
Expand All @@ -40,7 +41,7 @@ import {
gitStatusQueryOptions,
invalidateGitQueries,
} from "~/lib/gitReactQuery";
import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
import { resolvePathLinkTarget } from "~/terminal-links";
import { readNativeApi } from "~/nativeApi";

interface GitActionsControlProps {
Expand Down Expand Up @@ -569,7 +570,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
return;
}
const target = resolvePathLinkTarget(filePath, gitCwd);
void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
void openInPreferredEditor(api, target).catch((error) => {
toastManager.add({
type: "error",
title: "Unable to open file",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import {
useState,
} from "react";
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { openInPreferredEditor } from "../editorPreferences";
import {
extractTerminalLinks,
isTerminalLinkActivation,
preferredTerminalEditor,
resolvePathLinkTarget,
} from "../terminal-links";
import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
Expand Down Expand Up @@ -236,7 +236,7 @@ function TerminalViewport({
}

const target = resolvePathLinkTarget(match.text, cwd);
void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
void openInPreferredEditor(api, target).catch((error) => {
writeSystemMessage(
latestTerminal,
error instanceof Error ? error.message : "Unable to open path",
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/editorPreferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";

import {
readStoredPreferredEditor,
resolveAndPersistPreferredEditor,
resolvePreferredEditor,
} from "./editorPreferences";

function createStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem(key: string) {
return values.get(key) ?? null;
},
setItem(key: string, value: string) {
values.set(key, value);
},
};
}

describe("resolvePreferredEditor", () => {
it("prefers a stored editor when it is available", () => {
const storage = createStorage({ "t3code:last-editor": "vscode" });
expect(resolvePreferredEditor(["cursor", "vscode", "file-manager"], storage)).toBe("vscode");
});

it("falls back to the first available editor in configured preference order", () => {
const storage = createStorage();
expect(resolvePreferredEditor(["vscode", "file-manager"], storage)).toBe("vscode");
});

it("returns null when no editors are available", () => {
const storage = createStorage({ "t3code:last-editor": "cursor" });
expect(resolvePreferredEditor([], storage)).toBeNull();
});
});

describe("resolveAndPersistPreferredEditor", () => {
it("persists the inferred fallback editor", () => {
const storage = createStorage();
expect(resolveAndPersistPreferredEditor(["vscode", "file-manager"], storage)).toBe("vscode");
expect(readStoredPreferredEditor(storage)).toBe("vscode");
});
});
71 changes: 71 additions & 0 deletions apps/web/src/editorPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { EDITORS, type EditorId, type NativeApi } from "@t3tools/contracts";

const LAST_EDITOR_KEY = "t3code:last-editor";

type StorageLike = Pick<Storage, "getItem" | "setItem">;

function defaultStorage(): StorageLike | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}

function isEditorId(value: string | null): value is EditorId {
return EDITORS.some((editor) => editor.id === value);
}

export function readStoredPreferredEditor(
storage: StorageLike | null = defaultStorage(),
): EditorId | null {
const stored = storage?.getItem(LAST_EDITOR_KEY) ?? null;
return isEditorId(stored) ? stored : null;
}

export function writeStoredPreferredEditor(
editor: EditorId,
storage: StorageLike | null = defaultStorage(),
): void {
storage?.setItem(LAST_EDITOR_KEY, editor);
}

export function resolvePreferredEditor(
availableEditors: readonly EditorId[],
storage: StorageLike | null = defaultStorage(),
): EditorId | null {
const stored = readStoredPreferredEditor(storage);
if (stored && availableEditors.includes(stored)) {
return stored;
}

const availableEditorIds = new Set(availableEditors);
return EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null;
}

export function resolveAndPersistPreferredEditor(
availableEditors: readonly EditorId[],
storage: StorageLike | null = defaultStorage(),
): EditorId | null {
const editor = resolvePreferredEditor(availableEditors, storage);
if (editor) {
writeStoredPreferredEditor(editor, storage);
}
return editor;
}

export async function openInPreferredEditor(
api: Pick<NativeApi, "server" | "shell">,
targetPath: string,
storage: StorageLike | null = defaultStorage(),
): Promise<EditorId> {
const { availableEditors } = await api.server.getConfig();
const editor = resolveAndPersistPreferredEditor(availableEditors, storage);
if (!editor) {
throw new Error("No available editors found.");
}

await api.shell.openInEditor(targetPath, editor);
return editor;
}
12 changes: 8 additions & 4 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import { Throttler } from "@tanstack/react-pacer";
import { APP_DISPLAY_NAME } from "../branding";
import { Button } from "../components/ui/button";
import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
import { readNativeApi } from "../nativeApi";
import { useComposerDraftStore } from "../composerDraftStore";
import { useStore } from "../store";
import { useTerminalStateStore } from "../terminalStateStore";
import { preferredTerminalEditor } from "../terminal-links";
import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
import { providerQueryKeys } from "../lib/providerReactQuery";
Expand Down Expand Up @@ -278,9 +278,13 @@ function EventRouter() {
onClick: () => {
void queryClient
.ensureQueryData(serverConfigQueryOptions())
.then((config) =>
api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()),
)
.then((config) => {
const editor = resolveAndPersistPreferredEditor(config.availableEditors);
if (!editor) {
throw new Error("No available editors found.");
}
return api.shell.openInEditor(config.keybindingsConfigPath, editor);
})
.catch((error) => {
toastManager.add({
type: "error",
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import { useQuery } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { type ProviderKind } from "@t3tools/contracts";
import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";

import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { isElectron } from "../env";
import { useTheme } from "../hooks/useTheme";
import { serverConfigQueryOptions } from "../lib/serverReactQuery";
import { ensureNativeApi } from "../nativeApi";
import { preferredTerminalEditor } from "../terminal-links";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Switch } from "../components/ui/switch";
Expand Down Expand Up @@ -97,14 +96,21 @@ function SettingsRouteView() {
const codexBinaryPath = settings.codexBinaryPath;
const codexHomePath = settings.codexHomePath;
const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
const availableEditors = serverConfigQuery.data?.availableEditors;

const openKeybindingsFile = useCallback(() => {
if (!keybindingsConfigPath) return;
setOpenKeybindingsError(null);
setIsOpeningKeybindings(true);
const api = ensureNativeApi();
const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);
if (!editor) {
setOpenKeybindingsError("No available editors found.");
setIsOpeningKeybindings(false);
return;
}
void api.shell
.openInEditor(keybindingsConfigPath, preferredTerminalEditor())
.openInEditor(keybindingsConfigPath, editor)
.catch((error) => {
setOpenKeybindingsError(
error instanceof Error ? error.message : "Unable to open keybindings file.",
Expand All @@ -113,7 +119,7 @@ function SettingsRouteView() {
.finally(() => {
setIsOpeningKeybindings(false);
});
}, [keybindingsConfigPath]);
}, [availableEditors, keybindingsConfigPath]);

const addCustomModel = useCallback(
(provider: ProviderKind) => {
Expand Down
Loading