| author | |
| committer | |
| log | a01d712751f8103001b5e728d64c12bc7c3d652a |
| tree | 811330e0eea7ac18b113908337aab3ac9dfe062e |
| parent | 774cff9a74bbd937021e27da524b96dbb7319a7c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>15 files changed, 283 insertions(+), 259 deletions(-)
apps/web/src/appSettings.test.ts-15| ... | ... | @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | getAppModelOptions, |
| 5 | getSlashModelOptions, | |
| 6 | 5 | normalizeCustomModelSlugs, |
| 7 | 6 | resolveAppModelSelection, |
| 8 | 7 | } from "./appSettings"; |
| ... | ... | @@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => { |
| 58 | 57 | expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); |
| 59 | 58 | }); |
| 60 | 59 | }); |
| 61 | ||
| 62 | describe("getSlashModelOptions", () => { | |
| 63 | it("includes saved custom model slugs for /model command suggestions", () => { | |
| 64 | const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex"); | |
| 65 | ||
| 66 | expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); | |
| 67 | }); | |
| 68 | ||
| 69 | it("filters slash-model suggestions across built-in and custom model names", () => { | |
| 70 | const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); | |
| 71 | ||
| 72 | expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); | |
| 73 | }); | |
| 74 | }); |
apps/web/src/appSettings.ts+16-112| ... | ... | @@ -1,7 +1,8 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | |
| 1 | import { useCallback } from "react"; | |
| 2 | 2 | import { Option, Schema } from "effect"; |
| 3 | 3 | import { type ProviderKind } from "@t3tools/contracts"; |
| 4 | 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 5 | import { useLocalStorage } from "./hooks/useLocalStorage"; | |
| 5 | 6 | |
| 6 | 7 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 7 | 8 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| ... | ... | @@ -34,10 +35,6 @@ export interface AppModelOption { |
| 34 | 35 | |
| 35 | 36 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 36 | 37 | |
| 37 | let listeners: Array<() => void> = []; | |
| 38 | let cachedRawSettings: string | null | undefined; | |
| 39 | let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; | |
| 40 | ||
| 41 | 38 | export function normalizeCustomModelSlugs( |
| 42 | 39 | models: Iterable<string | null | undefined>, |
| 43 | 40 | provider: ProviderKind = "codex", |
| ... | ... | @@ -67,13 +64,6 @@ export function normalizeCustomModelSlugs( |
| 67 | 64 | return normalizedModels; |
| 68 | 65 | } |
| 69 | 66 | |
| 70 | function normalizeAppSettings(settings: AppSettings): AppSettings { | |
| 71 | return { | |
| 72 | ...settings, | |
| 73 | customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), | |
| 74 | }; | |
| 75 | } | |
| 76 | ||
| 77 | 67 | export function getAppModelOptions( |
| 78 | 68 | provider: ProviderKind, |
| 79 | 69 | customModels: readonly string[], |
| ... | ... | @@ -143,112 +133,26 @@ export function resolveAppModelSelection( |
| 143 | 133 | ); |
| 144 | 134 | } |
| 145 | 135 | |
| 146 | export function getSlashModelOptions( | |
| 147 | provider: ProviderKind, | |
| 148 | customModels: readonly string[], | |
| 149 | query: string, | |
| 150 | selectedModel?: string | null, | |
| 151 | ): AppModelOption[] { | |
| 152 | const normalizedQuery = query.trim().toLowerCase(); | |
| 153 | const options = getAppModelOptions(provider, customModels, selectedModel); | |
| 154 | if (!normalizedQuery) { | |
| 155 | return options; | |
| 156 | } | |
| 157 | ||
| 158 | return options.filter((option) => { | |
| 159 | const searchSlug = option.slug.toLowerCase(); | |
| 160 | const searchName = option.name.toLowerCase(); | |
| 161 | return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); | |
| 162 | }); | |
| 163 | } | |
| 164 | ||
| 165 | function emitChange(): void { | |
| 166 | for (const listener of listeners) { | |
| 167 | listener(); | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | function parsePersistedSettings(value: string | null): AppSettings { | |
| 172 | if (!value) { | |
| 173 | return DEFAULT_APP_SETTINGS; | |
| 174 | } | |
| 175 | ||
| 176 | try { | |
| 177 | return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); | |
| 178 | } catch { | |
| 179 | return DEFAULT_APP_SETTINGS; | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | export function getAppSettingsSnapshot(): AppSettings { | |
| 184 | if (typeof window === "undefined") { | |
| 185 | return DEFAULT_APP_SETTINGS; | |
| 186 | } | |
| 187 | ||
| 188 | const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); | |
| 189 | if (raw === cachedRawSettings) { | |
| 190 | return cachedSnapshot; | |
| 191 | } | |
| 192 | ||
| 193 | cachedRawSettings = raw; | |
| 194 | cachedSnapshot = parsePersistedSettings(raw); | |
| 195 | return cachedSnapshot; | |
| 196 | } | |
| 197 | ||
| 198 | function persistSettings(next: AppSettings): void { | |
| 199 | if (typeof window === "undefined") return; | |
| 200 | ||
| 201 | const raw = JSON.stringify(next); | |
| 202 | try { | |
| 203 | if (raw !== cachedRawSettings) { | |
| 204 | window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); | |
| 205 | } | |
| 206 | } catch { | |
| 207 | // Best-effort persistence only. | |
| 208 | } | |
| 209 | ||
| 210 | cachedRawSettings = raw; | |
| 211 | cachedSnapshot = next; | |
| 212 | } | |
| 213 | ||
| 214 | function subscribe(listener: () => void): () => void { | |
| 215 | listeners.push(listener); | |
| 216 | ||
| 217 | const onStorage = (event: StorageEvent) => { | |
| 218 | if (event.key === APP_SETTINGS_STORAGE_KEY) { | |
| 219 | emitChange(); | |
| 220 | } | |
| 221 | }; | |
| 222 | ||
| 223 | window.addEventListener("storage", onStorage); | |
| 224 | return () => { | |
| 225 | listeners = listeners.filter((entry) => entry !== listener); | |
| 226 | window.removeEventListener("storage", onStorage); | |
| 227 | }; | |
| 228 | } | |
| 229 | ||
| 230 | 136 | export function useAppSettings() { |
| 231 | const settings = useSyncExternalStore( | |
| 232 | subscribe, | |
| 233 | getAppSettingsSnapshot, | |
| 234 | () => DEFAULT_APP_SETTINGS, | |
| 137 | const [settings, setSettings] = useLocalStorage( | |
| 138 | APP_SETTINGS_STORAGE_KEY, | |
| 139 | DEFAULT_APP_SETTINGS, | |
| 140 | AppSettingsSchema, | |
| 235 | 141 | ); |
| 236 | 142 | |
| 237 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { | |
| 238 | const next = normalizeAppSettings( | |
| 239 | Schema.decodeSync(AppSettingsSchema)({ | |
| 240 | ...getAppSettingsSnapshot(), | |
| 143 | const updateSettings = useCallback( | |
| 144 | (patch: Partial<AppSettings>) => { | |
| 145 | setSettings((prev) => ({ | |
| 146 | ...prev, | |
| 241 | 147 | ...patch, |
| 242 | }), | |
| 243 | ); | |
| 244 | persistSettings(next); | |
| 245 | emitChange(); | |
| 246 | }, []); | |
| 148 | })); | |
| 149 | }, | |
| 150 | [setSettings], | |
| 151 | ); | |
| 247 | 152 | |
| 248 | 153 | const resetSettings = useCallback(() => { |
| 249 | persistSettings(DEFAULT_APP_SETTINGS); | |
| 250 | emitChange(); | |
| 251 | }, []); | |
| 154 | setSettings(DEFAULT_APP_SETTINGS); | |
| 155 | }, [setSettings]); | |
| 252 | 156 | |
| 253 | 157 | return { |
| 254 | 158 | settings, |
apps/web/src/components/ChatMarkdown.tsx+2-2| ... | ... | @@ -20,13 +20,13 @@ import React, { |
| 20 | 20 | import type { Components } from "react-markdown"; |
| 21 | 21 | import ReactMarkdown from "react-markdown"; |
| 22 | 22 | import remarkGfm from "remark-gfm"; |
| 23 | import { openInPreferredEditor } from "../editorPreferences"; | |
| 23 | 24 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 24 | 25 | import { fnv1a32 } from "../lib/diffRendering"; |
| 25 | 26 | import { LRUCache } from "../lib/lruCache"; |
| 26 | 27 | import { useTheme } from "../hooks/useTheme"; |
| 27 | 28 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 28 | 29 | import { readNativeApi } from "../nativeApi"; |
| 29 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 30 | 30 | |
| 31 | 31 | class CodeHighlightErrorBoundary extends React.Component< |
| 32 | 32 | { fallback: ReactNode; children: ReactNode }, |
| ... | ... | @@ -254,7 +254,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 254 | 254 | event.stopPropagation(); |
| 255 | 255 | const api = readNativeApi(); |
| 256 | 256 | if (api) { |
| 257 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | |
| 257 | void openInPreferredEditor(api, targetPath); | |
| 258 | 258 | } else { |
| 259 | 259 | console.warn("Native API not found. Unable to open file in editor."); |
| 260 | 260 | } |
apps/web/src/components/ChatView.logic.ts+3-18| ... | ... | @@ -1,29 +1,14 @@ |
| 1 | import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; | |
| 1 | import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { type ChatMessage, type Thread } from "../types"; |
| 3 | 3 | import { randomUUID } from "~/lib/utils"; |
| 4 | 4 | import { getAppModelOptions } from "../appSettings"; |
| 5 | 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 6 | import { Schema } from "effect"; | |
| 6 | 7 | |
| 7 | 8 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 8 | 9 | const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 9 | 10 | |
| 10 | export function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | |
| 11 | const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 12 | if (!stored) return {}; | |
| 13 | ||
| 14 | try { | |
| 15 | const parsed: unknown = JSON.parse(stored); | |
| 16 | if (!parsed || typeof parsed !== "object") return {}; | |
| 17 | return Object.fromEntries( | |
| 18 | Object.entries(parsed).filter( | |
| 19 | (entry): entry is [string, string] => | |
| 20 | typeof entry[0] === "string" && typeof entry[1] === "string", | |
| 21 | ), | |
| 22 | ); | |
| 23 | } catch { | |
| 24 | return {}; | |
| 25 | } | |
| 26 | } | |
| 11 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); | |
| 27 | 12 | |
| 28 | 13 | export function buildLocalDraftThread( |
| 29 | 14 | threadId: ThreadId, |
apps/web/src/components/ChatView.tsx+8-19| ... | ... | @@ -148,13 +148,14 @@ import { |
| 148 | 148 | collectUserMessageBlobPreviewUrls, |
| 149 | 149 | getCustomModelOptionsByProvider, |
| 150 | 150 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 151 | LastInvokedScriptByProjectSchema, | |
| 151 | 152 | PullRequestDialogState, |
| 152 | 153 | readFileAsDataUrl, |
| 153 | readLastInvokedScriptByProjectFromStorage, | |
| 154 | 154 | revokeBlobPreviewUrl, |
| 155 | 155 | revokeUserMessagePreviewUrls, |
| 156 | 156 | SendPhase, |
| 157 | 157 | } from "./ChatView.logic"; |
| 158 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | |
| 158 | 159 | |
| 159 | 160 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; |
| 160 | 161 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; |
| ... | ... | @@ -267,9 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 267 | 268 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => |
| 268 | 269 | detectComposerTrigger(prompt, prompt.length), |
| 269 | 270 | ); |
| 270 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< | |
| 271 | Record<string, string> | |
| 272 | >(() => readLastInvokedScriptByProjectFromStorage()); | |
| 271 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( | |
| 272 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | |
| 273 | {}, | |
| 274 | LastInvokedScriptByProjectSchema, | |
| 275 | ); | |
| 273 | 276 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 274 | 277 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); |
| 275 | 278 | const shouldAutoScrollRef = useRef(true); |
| ... | ... | @@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1196 | 1199 | setThreadError, |
| 1197 | 1200 | storeNewTerminal, |
| 1198 | 1201 | storeSetActiveTerminal, |
| 1202 | setLastInvokedScriptByProjectId, | |
| 1199 | 1203 | terminalState.activeTerminalId, |
| 1200 | 1204 | terminalState.runningTerminalIds, |
| 1201 | 1205 | terminalState.terminalIds, |
| ... | ... | @@ -1438,21 +1442,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1438 | 1442 | [serverThread], |
| 1439 | 1443 | ); |
| 1440 | 1444 | |
| 1441 | useEffect(() => { | |
| 1442 | try { | |
| 1443 | if (Object.keys(lastInvokedScriptByProjectId).length === 0) { | |
| 1444 | localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); | |
| 1445 | return; | |
| 1446 | } | |
| 1447 | localStorage.setItem( | |
| 1448 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | |
| 1449 | JSON.stringify(lastInvokedScriptByProjectId), | |
| 1450 | ); | |
| 1451 | } catch { | |
| 1452 | // Ignore storage write failures (private mode, quota exceeded, etc.) | |
| 1453 | } | |
| 1454 | }, [lastInvokedScriptByProjectId]); | |
| 1455 | ||
| 1456 | 1445 | // Auto-scroll on new messages |
| 1457 | 1446 | const messageCount = timelineMessages.length; |
| 1458 | 1447 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { |
apps/web/src/components/DiffPanel.tsx+3-2| ... | ... | @@ -12,11 +12,12 @@ import { |
| 12 | 12 | useRef, |
| 13 | 13 | useState, |
| 14 | 14 | } from "react"; |
| 15 | import { openInPreferredEditor } from "../editorPreferences"; | |
| 15 | 16 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; |
| 16 | 17 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 17 | 18 | import { cn } from "~/lib/utils"; |
| 18 | 19 | import { readNativeApi } from "../nativeApi"; |
| 19 | import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links"; | |
| 20 | import { resolvePathLinkTarget } from "../terminal-links"; | |
| 20 | 21 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 21 | 22 | import { isElectron } from "../env"; |
| 22 | 23 | import { useTheme } from "../hooks/useTheme"; |
| ... | ... | @@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 311 | 312 | const api = readNativeApi(); |
| 312 | 313 | if (!api) return; |
| 313 | 314 | const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; |
| 314 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()).catch((error) => { | |
| 315 | void openInPreferredEditor(api, targetPath).catch((error) => { | |
| 315 | 316 | console.warn("Failed to open diff file in editor.", error); |
| 316 | 317 | }); |
| 317 | 318 | }, |
apps/web/src/components/GitActionsControl.tsx+3-2| ... | ... | @@ -31,6 +31,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 31 | 31 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 32 | 32 | import { Textarea } from "~/components/ui/textarea"; |
| 33 | 33 | import { toastManager } from "~/components/ui/toast"; |
| 34 | import { openInPreferredEditor } from "~/editorPreferences"; | |
| 34 | 35 | import { |
| 35 | 36 | gitBranchesQueryOptions, |
| 36 | 37 | gitInitMutationOptions, |
| ... | ... | @@ -40,7 +41,7 @@ import { |
| 40 | 41 | gitStatusQueryOptions, |
| 41 | 42 | invalidateGitQueries, |
| 42 | 43 | } from "~/lib/gitReactQuery"; |
| 43 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; | |
| 44 | import { resolvePathLinkTarget } from "~/terminal-links"; | |
| 44 | 45 | import { readNativeApi } from "~/nativeApi"; |
| 45 | 46 | |
| 46 | 47 | interface GitActionsControlProps { |
| ... | ... | @@ -581,7 +582,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 581 | 582 | return; |
| 582 | 583 | } |
| 583 | 584 | const target = resolvePathLinkTarget(filePath, gitCwd); |
| 584 | void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { | |
| 585 | void openInPreferredEditor(api, target).catch((error) => { | |
| 585 | 586 | toastManager.add({ |
| 586 | 587 | type: "error", |
| 587 | 588 | title: "Unable to open file", |
apps/web/src/components/ThreadTerminalDrawer.tsx+2-2| ... | ... | @@ -12,10 +12,10 @@ import { |
| 12 | 12 | useState, |
| 13 | 13 | } from "react"; |
| 14 | 14 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 15 | import { openInPreferredEditor } from "../editorPreferences"; | |
| 15 | 16 | import { |
| 16 | 17 | extractTerminalLinks, |
| 17 | 18 | isTerminalLinkActivation, |
| 18 | preferredTerminalEditor, | |
| 19 | 19 | resolvePathLinkTarget, |
| 20 | 20 | } from "../terminal-links"; |
| 21 | 21 | import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; |
| ... | ... | @@ -236,7 +236,7 @@ function TerminalViewport({ |
| 236 | 236 | } |
| 237 | 237 | |
| 238 | 238 | const target = resolvePathLinkTarget(match.text, cwd); |
| 239 | void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { | |
| 239 | void openInPreferredEditor(api, target).catch((error) => { | |
| 240 | 240 | writeSystemMessage( |
| 241 | 241 | latestTerminal, |
| 242 | 242 | error instanceof Error ? error.message : "Unable to open path", |
apps/web/src/components/chat/OpenInPicker.tsx+45-54| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | |
| 1 | import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | |
| 2 | import { memo, useCallback, useEffect, useMemo } from "react"; | |
| 3 | 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 4 | import { usePreferredEditor } from "../../editorPreferences"; | |
| 4 | 5 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; |
| 5 | 6 | import { Button } from "../ui/button"; |
| 6 | 7 | import { Group, GroupSeparator } from "../ui/group"; |
| ... | ... | @@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; |
| 9 | 10 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 10 | 11 | import { readNativeApi } from "~/nativeApi"; |
| 11 | 12 | |
| 12 | const LAST_EDITOR_KEY = "t3code:last-editor"; | |
| 13 | const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => { | |
| 14 | const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ | |
| 15 | { | |
| 16 | label: "Cursor", | |
| 17 | Icon: CursorIcon, | |
| 18 | value: "cursor", | |
| 19 | }, | |
| 20 | { | |
| 21 | label: "VS Code", | |
| 22 | Icon: VisualStudioCode, | |
| 23 | value: "vscode", | |
| 24 | }, | |
| 25 | { | |
| 26 | label: "Zed", | |
| 27 | Icon: Zed, | |
| 28 | value: "zed", | |
| 29 | }, | |
| 30 | { | |
| 31 | label: isMacPlatform(platform) | |
| 32 | ? "Finder" | |
| 33 | : isWindowsPlatform(platform) | |
| 34 | ? "Explorer" | |
| 35 | : "Files", | |
| 36 | Icon: FolderClosedIcon, | |
| 37 | value: "file-manager", | |
| 38 | }, | |
| 39 | ]; | |
| 40 | return baseOptions.filter((option) => availableEditors.includes(option.value)); | |
| 41 | }; | |
| 13 | 42 | |
| 14 | 43 | export const OpenInPicker = memo(function OpenInPicker({ |
| 15 | 44 | keybindings, |
| ... | ... | @@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 20 | 49 | availableEditors: ReadonlyArray<EditorId>; |
| 21 | 50 | openInCwd: string | null; |
| 22 | 51 | }) { |
| 23 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | |
| 24 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | |
| 25 | return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; | |
| 26 | }); | |
| 27 | ||
| 28 | const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>( | |
| 29 | () => [ | |
| 30 | { | |
| 31 | label: "Cursor", | |
| 32 | Icon: CursorIcon, | |
| 33 | value: "cursor", | |
| 34 | }, | |
| 35 | { | |
| 36 | label: "VS Code", | |
| 37 | Icon: VisualStudioCode, | |
| 38 | value: "vscode", | |
| 39 | }, | |
| 40 | { | |
| 41 | label: "Zed", | |
| 42 | Icon: Zed, | |
| 43 | value: "zed", | |
| 44 | }, | |
| 45 | { | |
| 46 | label: isMacPlatform(navigator.platform) | |
| 47 | ? "Finder" | |
| 48 | : isWindowsPlatform(navigator.platform) | |
| 49 | ? "Explorer" | |
| 50 | : "Files", | |
| 51 | Icon: FolderClosedIcon, | |
| 52 | value: "file-manager", | |
| 53 | }, | |
| 54 | ], | |
| 55 | [], | |
| 56 | ); | |
| 52 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); | |
| 57 | 53 | const options = useMemo( |
| 58 | () => allOptions.filter((option) => availableEditors.includes(option.value)), | |
| 59 | [allOptions, availableEditors], | |
| 54 | () => resolveOptions(navigator.platform, availableEditors), | |
| 55 | [availableEditors], | |
| 60 | 56 | ); |
| 61 | ||
| 62 | const effectiveEditor = options.some((option) => option.value === lastEditor) | |
| 63 | ? lastEditor | |
| 64 | : (options[0]?.value ?? null); | |
| 65 | const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; | |
| 57 | const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; | |
| 66 | 58 | |
| 67 | 59 | const openInEditor = useCallback( |
| 68 | 60 | (editorId: EditorId | null) => { |
| 69 | 61 | const api = readNativeApi(); |
| 70 | 62 | if (!api || !openInCwd) return; |
| 71 | const editor = editorId ?? effectiveEditor; | |
| 63 | const editor = editorId ?? preferredEditor; | |
| 72 | 64 | if (!editor) return; |
| 73 | 65 | void api.shell.openInEditor(openInCwd, editor); |
| 74 | localStorage.setItem(LAST_EDITOR_KEY, editor); | |
| 75 | setLastEditor(editor); | |
| 66 | setPreferredEditor(editor); | |
| 76 | 67 | }, |
| 77 | [effectiveEditor, openInCwd, setLastEditor], | |
| 68 | [preferredEditor, openInCwd, setPreferredEditor], | |
| 78 | 69 | ); |
| 79 | 70 | |
| 80 | 71 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | ... | @@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 87 | 78 | const api = readNativeApi(); |
| 88 | 79 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 89 | 80 | if (!api || !openInCwd) return; |
| 90 | if (!effectiveEditor) return; | |
| 81 | if (!preferredEditor) return; | |
| 91 | 82 | |
| 92 | 83 | e.preventDefault(); |
| 93 | void api.shell.openInEditor(openInCwd, effectiveEditor); | |
| 84 | void api.shell.openInEditor(openInCwd, preferredEditor); | |
| 94 | 85 | }; |
| 95 | 86 | window.addEventListener("keydown", handler); |
| 96 | 87 | return () => window.removeEventListener("keydown", handler); |
| 97 | }, [effectiveEditor, keybindings, openInCwd]); | |
| 88 | }, [preferredEditor, keybindings, openInCwd]); | |
| 98 | 89 | |
| 99 | 90 | return ( |
| 100 | 91 | <Group aria-label="Subscription actions"> |
| 101 | 92 | <Button |
| 102 | 93 | size="xs" |
| 103 | 94 | variant="outline" |
| 104 | disabled={!effectiveEditor || !openInCwd} | |
| 105 | onClick={() => openInEditor(effectiveEditor)} | |
| 95 | disabled={!preferredEditor || !openInCwd} | |
| 96 | onClick={() => openInEditor(preferredEditor)} | |
| 106 | 97 | > |
| 107 | 98 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} |
| 108 | 99 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> |
| ... | ... | @@ -120,7 +111,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 120 | 111 | <MenuItem key={value} onClick={() => openInEditor(value)}> |
| 121 | 112 | <Icon aria-hidden="true" className="text-muted-foreground" /> |
| 122 | 113 | {label} |
| 123 | {value === effectiveEditor && openFavoriteEditorShortcutLabel && ( | |
| 114 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( | |
| 124 | 115 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> |
| 125 | 116 | )} |
| 126 | 117 | </MenuItem> |
apps/web/src/components/ui/sidebar.tsx+5-3| ... | ... | @@ -18,6 +18,8 @@ import { |
| 18 | 18 | import { Skeleton } from "~/components/ui/skeleton"; |
| 19 | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 20 | 20 | import { useMediaQuery } from "~/hooks/useMediaQuery"; |
| 21 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; | |
| 22 | import { Schema } from "effect"; | |
| 21 | 23 | |
| 22 | 24 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; |
| 23 | 25 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; |
| ... | ... | @@ -371,7 +373,7 @@ function SidebarRail({ |
| 371 | 373 | element.style.removeProperty("transition-duration"); |
| 372 | 374 | }); |
| 373 | 375 | if (resolvedResizable?.storageKey && typeof window !== "undefined") { |
| 374 | window.localStorage.setItem(resolvedResizable.storageKey, String(resizeState.width)); | |
| 376 | setLocalStorageItem(resolvedResizable.storageKey, resizeState.width, Schema.Finite); | |
| 375 | 377 | } |
| 376 | 378 | resolvedResizable?.onResize?.(resizeState.width); |
| 377 | 379 | resizeStateRef.current = null; |
| ... | ... | @@ -541,8 +543,8 @@ function SidebarRail({ |
| 541 | 543 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); |
| 542 | 544 | if (!wrapper) return; |
| 543 | 545 | |
| 544 | const storedWidth = Number(window.localStorage.getItem(resolvedResizable.storageKey)); | |
| 545 | if (!Number.isFinite(storedWidth)) return; | |
| 546 | const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); | |
| 547 | if (storedWidth === null) return; | |
| 546 | 548 | const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); |
| 547 | 549 | wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); |
| 548 | 550 | resolvedResizable.onResize?.(clampedWidth); |
apps/web/src/editorPreferences.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts"; | |
| 2 | import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; | |
| 3 | import { useMemo } from "react"; | |
| 4 | ||
| 5 | const LAST_EDITOR_KEY = "t3code:last-editor"; | |
| 6 | ||
| 7 | export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) { | |
| 8 | const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); | |
| 9 | ||
| 10 | const effectiveEditor = useMemo(() => { | |
| 11 | if (lastEditor && availableEditors.includes(lastEditor)) return lastEditor; | |
| 12 | return EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null; | |
| 13 | }, [lastEditor, availableEditors]); | |
| 14 | ||
| 15 | return [effectiveEditor, setLastEditor] as const; | |
| 16 | } | |
| 17 | ||
| 18 | export function resolveAndPersistPreferredEditor( | |
| 19 | availableEditors: readonly EditorId[], | |
| 20 | ): EditorId | null { | |
| 21 | const availableEditorIds = new Set(availableEditors); | |
| 22 | const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); | |
| 23 | if (stored && availableEditorIds.has(stored)) return stored; | |
| 24 | const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; | |
| 25 | if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); | |
| 26 | return editor ?? null; | |
| 27 | } | |
| 28 | ||
| 29 | export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise<EditorId> { | |
| 30 | const { availableEditors } = await api.server.getConfig(); | |
| 31 | const editor = resolveAndPersistPreferredEditor(availableEditors); | |
| 32 | if (!editor) throw new Error("No available editors found."); | |
| 33 | await api.shell.openInEditor(targetPath, editor); | |
| 34 | return editor; | |
| 35 | } |
apps/web/src/hooks/useLocalStorage.ts created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import * as Record from "effect/Record"; | |
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 4 | ||
| 5 | const isomorphicLocalStorage: Storage = | |
| 6 | typeof window !== "undefined" | |
| 7 | ? window.localStorage | |
| 8 | : (function () { | |
| 9 | const store = new Map<string, string>(); | |
| 10 | return { | |
| 11 | clear: () => store.clear(), | |
| 12 | getItem: (_) => store.get(_) ?? null, | |
| 13 | key: (_) => Record.keys(store).at(_) ?? null, | |
| 14 | get length() { | |
| 15 | return store.size; | |
| 16 | }, | |
| 17 | removeItem: (_) => store.delete(_), | |
| 18 | setItem: (_, value) => store.set(_, value), | |
| 19 | }; | |
| 20 | })(); | |
| 21 | ||
| 22 | const decode = <T, E>(schema: Schema.Codec<T, E>, value: string) => | |
| 23 | Schema.decodeSync(Schema.fromJsonString(schema))(value); | |
| 24 | ||
| 25 | const encode = <T, E>(schema: Schema.Codec<T, E>, value: T) => | |
| 26 | Schema.encodeSync(Schema.fromJsonString(schema))(value); | |
| 27 | ||
| 28 | export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => { | |
| 29 | const item = isomorphicLocalStorage.getItem(key); | |
| 30 | return item ? decode(schema, item) : null; | |
| 31 | }; | |
| 32 | ||
| 33 | export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => { | |
| 34 | const valueToSet = encode(schema, value); | |
| 35 | isomorphicLocalStorage.setItem(key, valueToSet); | |
| 36 | }; | |
| 37 | ||
| 38 | export const removeLocalStorageItem = (key: string) => { | |
| 39 | isomorphicLocalStorage.removeItem(key); | |
| 40 | }; | |
| 41 | ||
| 42 | const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; | |
| 43 | ||
| 44 | interface LocalStorageChangeDetail { | |
| 45 | key: string; | |
| 46 | } | |
| 47 | ||
| 48 | function dispatchLocalStorageChange(key: string) { | |
| 49 | if (typeof window === "undefined") return; | |
| 50 | window.dispatchEvent( | |
| 51 | new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, { | |
| 52 | detail: { key }, | |
| 53 | }), | |
| 54 | ); | |
| 55 | } | |
| 56 | ||
| 57 | export function useLocalStorage<T, E>( | |
| 58 | key: string, | |
| 59 | initialValue: T, | |
| 60 | schema: Schema.Codec<T, E>, | |
| 61 | ): [T, (value: T | ((val: T) => T)) => void] { | |
| 62 | // Get the initial value from localStorage or use the provided initialValue | |
| 63 | const [storedValue, setStoredValue] = useState<T>(() => { | |
| 64 | try { | |
| 65 | const item = getLocalStorageItem(key, schema); | |
| 66 | return item ?? initialValue; | |
| 67 | } catch (error) { | |
| 68 | console.error("[LOCALSTORAGE] Error:", error); | |
| 69 | return initialValue; | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | // Return a wrapped version of useState's setter function that persists the new value to localStorage | |
| 74 | const setValue = useCallback( | |
| 75 | (value: T | ((val: T) => T)) => { | |
| 76 | try { | |
| 77 | setStoredValue((prev) => { | |
| 78 | const valueToStore = typeof value === "function" ? (value as (val: T) => T)(prev) : value; | |
| 79 | if (valueToStore === null) { | |
| 80 | removeLocalStorageItem(key); | |
| 81 | } else { | |
| 82 | setLocalStorageItem(key, valueToStore, schema); | |
| 83 | } | |
| 84 | // Dispatch event after state update completes to avoid nested state updates | |
| 85 | queueMicrotask(() => dispatchLocalStorageChange(key)); | |
| 86 | return valueToStore; | |
| 87 | }); | |
| 88 | } catch (error) { | |
| 89 | console.error("[LOCALSTORAGE] Error:", error); | |
| 90 | } | |
| 91 | }, | |
| 92 | [key, schema], | |
| 93 | ); | |
| 94 | ||
| 95 | const prevKeyRef = useRef(key); | |
| 96 | ||
| 97 | // Re-sync from localStorage when key changes | |
| 98 | useEffect(() => { | |
| 99 | if (prevKeyRef.current !== key) { | |
| 100 | prevKeyRef.current = key; | |
| 101 | try { | |
| 102 | const newValue = getLocalStorageItem(key, schema); | |
| 103 | setStoredValue(newValue ?? initialValue); | |
| 104 | } catch (error) { | |
| 105 | console.error("[LOCALSTORAGE] Error:", error); | |
| 106 | } | |
| 107 | } | |
| 108 | }, [key, initialValue, schema]); | |
| 109 | ||
| 110 | // Listen for storage events from other tabs AND custom events from the same tab | |
| 111 | useEffect(() => { | |
| 112 | const syncFromStorage = () => { | |
| 113 | try { | |
| 114 | const newValue = getLocalStorageItem(key, schema); | |
| 115 | setStoredValue(newValue ?? initialValue); | |
| 116 | } catch (error) { | |
| 117 | console.error("[LOCALSTORAGE] Error:", error); | |
| 118 | } | |
| 119 | }; | |
| 120 | ||
| 121 | const handleStorageChange = (event: StorageEvent) => { | |
| 122 | if (event.key === key) { | |
| 123 | syncFromStorage(); | |
| 124 | } | |
| 125 | }; | |
| 126 | ||
| 127 | const handleLocalChange = (event: CustomEvent<LocalStorageChangeDetail>) => { | |
| 128 | if (event.detail.key === key) { | |
| 129 | syncFromStorage(); | |
| 130 | } | |
| 131 | }; | |
| 132 | ||
| 133 | window.addEventListener("storage", handleStorageChange); | |
| 134 | window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); | |
| 135 | ||
| 136 | return () => { | |
| 137 | window.removeEventListener("storage", handleStorageChange); | |
| 138 | window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener); | |
| 139 | }; | |
| 140 | }, [key, initialValue, schema]); | |
| 141 | ||
| 142 | return [storedValue, setValue]; | |
| 143 | } |
apps/web/src/routes/__root.tsx+8-4| ... | ... | @@ -13,12 +13,12 @@ import { Throttler } from "@tanstack/react-pacer"; |
| 13 | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | 14 | import { Button } from "../components/ui/button"; |
| 15 | 15 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 16 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | |
| 16 | 17 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; |
| 17 | 18 | import { readNativeApi } from "../nativeApi"; |
| 18 | 19 | import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; |
| 19 | 20 | import { useStore } from "../store"; |
| 20 | 21 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 21 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 22 | 22 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; |
| 23 | 23 | import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; |
| 24 | 24 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| ... | ... | @@ -281,9 +281,13 @@ function EventRouter() { |
| 281 | 281 | onClick: () => { |
| 282 | 282 | void queryClient |
| 283 | 283 | .ensureQueryData(serverConfigQueryOptions()) |
| 284 | .then((config) => | |
| 285 | api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()), | |
| 286 | ) | |
| 284 | .then((config) => { | |
| 285 | const editor = resolveAndPersistPreferredEditor(config.availableEditors); | |
| 286 | if (!editor) { | |
| 287 | throw new Error("No available editors found."); | |
| 288 | } | |
| 289 | return api.shell.openInEditor(config.keybindingsConfigPath, editor); | |
| 290 | }) | |
| 287 | 291 | .catch((error) => { |
| 288 | 292 | toastManager.add({ |
| 289 | 293 | type: "error", |
apps/web/src/routes/_chat.settings.tsx+10-4| ... | ... | @@ -3,13 +3,12 @@ import { useQuery } from "@tanstack/react-query"; |
| 3 | 3 | import { useCallback, useState } from "react"; |
| 4 | 4 | import { type ProviderKind } from "@t3tools/contracts"; |
| 5 | 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 6 | ||
| 7 | 6 | import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; |
| 7 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | |
| 8 | 8 | import { isElectron } from "../env"; |
| 9 | 9 | import { useTheme } from "../hooks/useTheme"; |
| 10 | 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 11 | 11 | import { ensureNativeApi } from "../nativeApi"; |
| 12 | import { preferredTerminalEditor } from "../terminal-links"; | |
| 13 | 12 | import { Button } from "../components/ui/button"; |
| 14 | 13 | import { Input } from "../components/ui/input"; |
| 15 | 14 | import { Switch } from "../components/ui/switch"; |
| ... | ... | @@ -98,14 +97,21 @@ function SettingsRouteView() { |
| 98 | 97 | const codexBinaryPath = settings.codexBinaryPath; |
| 99 | 98 | const codexHomePath = settings.codexHomePath; |
| 100 | 99 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 100 | const availableEditors = serverConfigQuery.data?.availableEditors; | |
| 101 | 101 | |
| 102 | 102 | const openKeybindingsFile = useCallback(() => { |
| 103 | 103 | if (!keybindingsConfigPath) return; |
| 104 | 104 | setOpenKeybindingsError(null); |
| 105 | 105 | setIsOpeningKeybindings(true); |
| 106 | 106 | const api = ensureNativeApi(); |
| 107 | const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); | |
| 108 | if (!editor) { | |
| 109 | setOpenKeybindingsError("No available editors found."); | |
| 110 | setIsOpeningKeybindings(false); | |
| 111 | return; | |
| 112 | } | |
| 107 | 113 | void api.shell |
| 108 | .openInEditor(keybindingsConfigPath, preferredTerminalEditor()) | |
| 114 | .openInEditor(keybindingsConfigPath, editor) | |
| 109 | 115 | .catch((error) => { |
| 110 | 116 | setOpenKeybindingsError( |
| 111 | 117 | error instanceof Error ? error.message : "Unable to open keybindings file.", |
| ... | ... | @@ -114,7 +120,7 @@ function SettingsRouteView() { |
| 114 | 120 | .finally(() => { |
| 115 | 121 | setIsOpeningKeybindings(false); |
| 116 | 122 | }); |
| 117 | }, [keybindingsConfigPath]); | |
| 123 | }, [availableEditors, keybindingsConfigPath]); | |
| 118 | 124 | |
| 119 | 125 | const addCustomModel = useCallback( |
| 120 | 126 | (provider: ProviderKind) => { |
apps/web/src/terminal-links.ts-22| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | |
| 2 | 1 | import { isMacPlatform } from "./lib/utils"; |
| 3 | 2 | |
| 4 | 3 | export type TerminalLinkKind = "url" | "path"; |
| ... | ... | @@ -14,7 +13,6 @@ const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; |
| 14 | 13 | const FILE_PATH_PATTERN = |
| 15 | 14 | /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; |
| 16 | 15 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; |
| 17 | const LAST_EDITOR_KEY = "t3code:last-editor"; | |
| 18 | 16 | |
| 19 | 17 | function trimClosingDelimiters(value: string): string { |
| 20 | 18 | let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); |
| ... | ... | @@ -175,23 +173,3 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { |
| 175 | 173 | if (!line) return resolvedPath; |
| 176 | 174 | return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; |
| 177 | 175 | } |
| 178 | ||
| 179 | export function preferredTerminalEditor(): EditorId { | |
| 180 | const fallback = EDITORS.find((editor) => editor.command)?.id ?? EDITORS[0]?.id ?? "cursor"; | |
| 181 | ||
| 182 | if (typeof window === "undefined") { | |
| 183 | return fallback; | |
| 184 | } | |
| 185 | ||
| 186 | const storedEditor = window.localStorage.getItem(LAST_EDITOR_KEY); | |
| 187 | if (!storedEditor) { | |
| 188 | return fallback; | |
| 189 | } | |
| 190 | ||
| 191 | const configured = EDITORS.find((editor) => editor.id === storedEditor); | |
| 192 | if (!configured?.command) { | |
| 193 | return fallback; | |
| 194 | } | |
| 195 | ||
| 196 | return configured.id; | |
| 197 | } |