| 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,7 +2,6 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | getAppModelOptions, | 4 | getAppModelOptions, |
| 5 | getSlashModelOptions, | ||
| 6 | normalizeCustomModelSlugs, | 5 | normalizeCustomModelSlugs, |
| 7 | resolveAppModelSelection, | 6 | resolveAppModelSelection, |
| 8 | } from "./appSettings"; | 7 | } from "./appSettings"; |
| ... | @@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => { | ... | @@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => { |
| 58 | expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); | 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,7 +1,8 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | 1 | import { useCallback } from "react"; |
| 2 | import { Option, Schema } from "effect"; | 2 | import { Option, Schema } from "effect"; |
| 3 | import { type ProviderKind } from "@t3tools/contracts"; | 3 | import { type ProviderKind } from "@t3tools/contracts"; |
| 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 5 | import { useLocalStorage } from "./hooks/useLocalStorage"; | ||
| 5 | 6 | ||
| 6 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | 7 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 7 | const MAX_CUSTOM_MODEL_COUNT = 32; | 8 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| ... | @@ -34,10 +35,6 @@ export interface AppModelOption { | ... | @@ -34,10 +35,6 @@ export interface AppModelOption { |
| 34 | 35 | ||
| 35 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | 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 | export function normalizeCustomModelSlugs( | 38 | export function normalizeCustomModelSlugs( |
| 42 | models: Iterable<string | null | undefined>, | 39 | models: Iterable<string | null | undefined>, |
| 43 | provider: ProviderKind = "codex", | 40 | provider: ProviderKind = "codex", |
| ... | @@ -67,13 +64,6 @@ export function normalizeCustomModelSlugs( | ... | @@ -67,13 +64,6 @@ export function normalizeCustomModelSlugs( |
| 67 | return normalizedModels; | 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 | export function getAppModelOptions( | 67 | export function getAppModelOptions( |
| 78 | provider: ProviderKind, | 68 | provider: ProviderKind, |
| 79 | customModels: readonly string[], | 69 | customModels: readonly string[], |
| ... | @@ -143,112 +133,26 @@ export function resolveAppModelSelection( | ... | @@ -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 | export function useAppSettings() { | 136 | export function useAppSettings() { |
| 231 | const settings = useSyncExternalStore( | 137 | const [settings, setSettings] = useLocalStorage( |
| 232 | subscribe, | 138 | APP_SETTINGS_STORAGE_KEY, |
| 233 | getAppSettingsSnapshot, | 139 | DEFAULT_APP_SETTINGS, |
| 234 | () => DEFAULT_APP_SETTINGS, | 140 | AppSettingsSchema, |
| 235 | ); | 141 | ); |
| 236 | 142 | ||
| 237 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { | 143 | const updateSettings = useCallback( |
| 238 | const next = normalizeAppSettings( | 144 | (patch: Partial<AppSettings>) => { |
| 239 | Schema.decodeSync(AppSettingsSchema)({ | 145 | setSettings((prev) => ({ |
| 240 | ...getAppSettingsSnapshot(), | 146 | ...prev, |
| 241 | ...patch, | 147 | ...patch, |
| 242 | }), | 148 | })); |
| 243 | ); | 149 | }, |
| 244 | persistSettings(next); | 150 | [setSettings], |
| 245 | emitChange(); | 151 | ); |
| 246 | }, []); | ||
| 247 | 152 | ||
| 248 | const resetSettings = useCallback(() => { | 153 | const resetSettings = useCallback(() => { |
| 249 | persistSettings(DEFAULT_APP_SETTINGS); | 154 | setSettings(DEFAULT_APP_SETTINGS); |
| 250 | emitChange(); | 155 | }, [setSettings]); |
| 251 | }, []); | ||
| 252 | 156 | ||
| 253 | return { | 157 | return { |
| 254 | settings, | 158 | settings, |
apps/web/src/components/ChatMarkdown.tsx+2-2| ... | @@ -20,13 +20,13 @@ import React, { | ... | @@ -20,13 +20,13 @@ import React, { |
| 20 | import type { Components } from "react-markdown"; | 20 | import type { Components } from "react-markdown"; |
| 21 | import ReactMarkdown from "react-markdown"; | 21 | import ReactMarkdown from "react-markdown"; |
| 22 | import remarkGfm from "remark-gfm"; | 22 | import remarkGfm from "remark-gfm"; |
| 23 | import { openInPreferredEditor } from "../editorPreferences"; | ||
| 23 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 24 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 24 | import { fnv1a32 } from "../lib/diffRendering"; | 25 | import { fnv1a32 } from "../lib/diffRendering"; |
| 25 | import { LRUCache } from "../lib/lruCache"; | 26 | import { LRUCache } from "../lib/lruCache"; |
| 26 | import { useTheme } from "../hooks/useTheme"; | 27 | import { useTheme } from "../hooks/useTheme"; |
| 27 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; | 28 | import { resolveMarkdownFileLinkTarget } from "../markdown-links"; |
| 28 | import { readNativeApi } from "../nativeApi"; | 29 | import { readNativeApi } from "../nativeApi"; |
| 29 | import { preferredTerminalEditor } from "../terminal-links"; | ||
| 30 | 30 | ||
| 31 | class CodeHighlightErrorBoundary extends React.Component< | 31 | class CodeHighlightErrorBoundary extends React.Component< |
| 32 | { fallback: ReactNode; children: ReactNode }, | 32 | { fallback: ReactNode; children: ReactNode }, |
| ... | @@ -254,7 +254,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | ... | @@ -254,7 +254,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 254 | event.stopPropagation(); | 254 | event.stopPropagation(); |
| 255 | const api = readNativeApi(); | 255 | const api = readNativeApi(); |
| 256 | if (api) { | 256 | if (api) { |
| 257 | void api.shell.openInEditor(targetPath, preferredTerminalEditor()); | 257 | void openInPreferredEditor(api, targetPath); |
| 258 | } else { | 258 | } else { |
| 259 | console.warn("Native API not found. Unable to open file in editor."); | 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,29 +1,14 @@ |
| 1 | import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; |
| 2 | import { type ChatMessage, type Thread } from "../types"; | 2 | import { type ChatMessage, type Thread } from "../types"; |
| 3 | import { randomUUID } from "~/lib/utils"; | 3 | import { randomUUID } from "~/lib/utils"; |
| 4 | import { getAppModelOptions } from "../appSettings"; | 4 | import { getAppModelOptions } from "../appSettings"; |
| 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 6 | import { Schema } from "effect"; | ||
| 6 | 7 | ||
| 7 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 8 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 8 | const WORKTREE_BRANCH_PREFIX = "t3code"; | 9 | const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 9 | 10 | ||
| 10 | export function readLastInvokedScriptByProjectFromStorage(): Record<string, string> { | 11 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.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 | } | ||
| 27 | 12 | ||
| 28 | export function buildLocalDraftThread( | 13 | export function buildLocalDraftThread( |
| 29 | threadId: ThreadId, | 14 | threadId: ThreadId, |
apps/web/src/components/ChatView.tsx+8-19| ... | @@ -148,13 +148,14 @@ import { | ... | @@ -148,13 +148,14 @@ import { |
| 148 | collectUserMessageBlobPreviewUrls, | 148 | collectUserMessageBlobPreviewUrls, |
| 149 | getCustomModelOptionsByProvider, | 149 | getCustomModelOptionsByProvider, |
| 150 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, | 150 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 151 | LastInvokedScriptByProjectSchema, | ||
| 151 | PullRequestDialogState, | 152 | PullRequestDialogState, |
| 152 | readFileAsDataUrl, | 153 | readFileAsDataUrl, |
| 153 | readLastInvokedScriptByProjectFromStorage, | ||
| 154 | revokeBlobPreviewUrl, | 154 | revokeBlobPreviewUrl, |
| 155 | revokeUserMessagePreviewUrls, | 155 | revokeUserMessagePreviewUrls, |
| 156 | SendPhase, | 156 | SendPhase, |
| 157 | } from "./ChatView.logic"; | 157 | } from "./ChatView.logic"; |
| 158 | import { useLocalStorage } from "~/hooks/useLocalStorage"; | ||
| 158 | 159 | ||
| 159 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; | 160 | const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; |
| 160 | const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; | 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,9 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 267 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => | 268 | const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() => |
| 268 | detectComposerTrigger(prompt, prompt.length), | 269 | detectComposerTrigger(prompt, prompt.length), |
| 269 | ); | 270 | ); |
| 270 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< | 271 | const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( |
| 271 | Record<string, string> | 272 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 272 | >(() => readLastInvokedScriptByProjectFromStorage()); | 273 | {}, |
| 274 | LastInvokedScriptByProjectSchema, | ||
| 275 | ); | ||
| 273 | const messagesScrollRef = useRef<HTMLDivElement>(null); | 276 | const messagesScrollRef = useRef<HTMLDivElement>(null); |
| 274 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); | 277 | const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null); |
| 275 | const shouldAutoScrollRef = useRef(true); | 278 | const shouldAutoScrollRef = useRef(true); |
| ... | @@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1196 | setThreadError, | 1199 | setThreadError, |
| 1197 | storeNewTerminal, | 1200 | storeNewTerminal, |
| 1198 | storeSetActiveTerminal, | 1201 | storeSetActiveTerminal, |
| 1202 | setLastInvokedScriptByProjectId, | ||
| 1199 | terminalState.activeTerminalId, | 1203 | terminalState.activeTerminalId, |
| 1200 | terminalState.runningTerminalIds, | 1204 | terminalState.runningTerminalIds, |
| 1201 | terminalState.terminalIds, | 1205 | terminalState.terminalIds, |
| ... | @@ -1438,21 +1442,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1438,21 +1442,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1438 | [serverThread], | 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 | // Auto-scroll on new messages | 1445 | // Auto-scroll on new messages |
| 1457 | const messageCount = timelineMessages.length; | 1446 | const messageCount = timelineMessages.length; |
| 1458 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { | 1447 | const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { |
apps/web/src/components/DiffPanel.tsx+3-2| ... | @@ -12,11 +12,12 @@ import { | ... | @@ -12,11 +12,12 @@ import { |
| 12 | useRef, | 12 | useRef, |
| 13 | useState, | 13 | useState, |
| 14 | } from "react"; | 14 | } from "react"; |
| 15 | import { openInPreferredEditor } from "../editorPreferences"; | ||
| 15 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | 16 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; |
| 16 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 17 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 17 | import { cn } from "~/lib/utils"; | 18 | import { cn } from "~/lib/utils"; |
| 18 | import { readNativeApi } from "../nativeApi"; | 19 | import { readNativeApi } from "../nativeApi"; |
| 19 | import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links"; | 20 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 20 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 21 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 21 | import { isElectron } from "../env"; | 22 | import { isElectron } from "../env"; |
| 22 | import { useTheme } from "../hooks/useTheme"; | 23 | import { useTheme } from "../hooks/useTheme"; |
| ... | @@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 311 | const api = readNativeApi(); | 312 | const api = readNativeApi(); |
| 312 | if (!api) return; | 313 | if (!api) return; |
| 313 | const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; | 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 | console.warn("Failed to open diff file in editor.", error); | 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,6 +31,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 31 | import { ScrollArea } from "~/components/ui/scroll-area"; | 31 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 32 | import { Textarea } from "~/components/ui/textarea"; | 32 | import { Textarea } from "~/components/ui/textarea"; |
| 33 | import { toastManager } from "~/components/ui/toast"; | 33 | import { toastManager } from "~/components/ui/toast"; |
| 34 | import { openInPreferredEditor } from "~/editorPreferences"; | ||
| 34 | import { | 35 | import { |
| 35 | gitBranchesQueryOptions, | 36 | gitBranchesQueryOptions, |
| 36 | gitInitMutationOptions, | 37 | gitInitMutationOptions, |
| ... | @@ -40,7 +41,7 @@ import { | ... | @@ -40,7 +41,7 @@ import { |
| 40 | gitStatusQueryOptions, | 41 | gitStatusQueryOptions, |
| 41 | invalidateGitQueries, | 42 | invalidateGitQueries, |
| 42 | } from "~/lib/gitReactQuery"; | 43 | } from "~/lib/gitReactQuery"; |
| 43 | import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; | 44 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 44 | import { readNativeApi } from "~/nativeApi"; | 45 | import { readNativeApi } from "~/nativeApi"; |
| 45 | 46 | ||
| 46 | interface GitActionsControlProps { | 47 | interface GitActionsControlProps { |
| ... | @@ -581,7 +582,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -581,7 +582,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 581 | return; | 582 | return; |
| 582 | } | 583 | } |
| 583 | const target = resolvePathLinkTarget(filePath, gitCwd); | 584 | const target = resolvePathLinkTarget(filePath, gitCwd); |
| 584 | void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { | 585 | void openInPreferredEditor(api, target).catch((error) => { |
| 585 | toastManager.add({ | 586 | toastManager.add({ |
| 586 | type: "error", | 587 | type: "error", |
| 587 | title: "Unable to open file", | 588 | title: "Unable to open file", |
apps/web/src/components/ThreadTerminalDrawer.tsx+2-2| ... | @@ -12,10 +12,10 @@ import { | ... | @@ -12,10 +12,10 @@ import { |
| 12 | useState, | 12 | useState, |
| 13 | } from "react"; | 13 | } from "react"; |
| 14 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | 14 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 15 | import { openInPreferredEditor } from "../editorPreferences"; | ||
| 15 | import { | 16 | import { |
| 16 | extractTerminalLinks, | 17 | extractTerminalLinks, |
| 17 | isTerminalLinkActivation, | 18 | isTerminalLinkActivation, |
| 18 | preferredTerminalEditor, | ||
| 19 | resolvePathLinkTarget, | 19 | resolvePathLinkTarget, |
| 20 | } from "../terminal-links"; | 20 | } from "../terminal-links"; |
| 21 | import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; | 21 | import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; |
| ... | @@ -236,7 +236,7 @@ function TerminalViewport({ | ... | @@ -236,7 +236,7 @@ function TerminalViewport({ |
| 236 | } | 236 | } |
| 237 | 237 | ||
| 238 | const target = resolvePathLinkTarget(match.text, cwd); | 238 | const target = resolvePathLinkTarget(match.text, cwd); |
| 239 | void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { | 239 | void openInPreferredEditor(api, target).catch((error) => { |
| 240 | writeSystemMessage( | 240 | writeSystemMessage( |
| 241 | latestTerminal, | 241 | latestTerminal, |
| 242 | error instanceof Error ? error.message : "Unable to open path", | 242 | error instanceof Error ? error.message : "Unable to open path", |
apps/web/src/components/chat/OpenInPicker.tsx+45-54| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 1 | import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { memo, useCallback, useEffect, useMemo } from "react"; |
| 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; | 3 | import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; |
| 4 | import { usePreferredEditor } from "../../editorPreferences"; | ||
| 4 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; | 5 | import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; |
| 5 | import { Button } from "../ui/button"; | 6 | import { Button } from "../ui/button"; |
| 6 | import { Group, GroupSeparator } from "../ui/group"; | 7 | import { Group, GroupSeparator } from "../ui/group"; |
| ... | @@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; | ... | @@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; |
| 9 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 10 | import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 10 | import { readNativeApi } from "~/nativeApi"; | 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 | export const OpenInPicker = memo(function OpenInPicker({ | 43 | export const OpenInPicker = memo(function OpenInPicker({ |
| 15 | keybindings, | 44 | keybindings, |
| ... | @@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 20 | availableEditors: ReadonlyArray<EditorId>; | 49 | availableEditors: ReadonlyArray<EditorId>; |
| 21 | openInCwd: string | null; | 50 | openInCwd: string | null; |
| 22 | }) { | 51 | }) { |
| 23 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | 52 | const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); |
| 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 | ); | ||
| 57 | const options = useMemo( | 53 | const options = useMemo( |
| 58 | () => allOptions.filter((option) => availableEditors.includes(option.value)), | 54 | () => resolveOptions(navigator.platform, availableEditors), |
| 59 | [allOptions, availableEditors], | 55 | [availableEditors], |
| 60 | ); | 56 | ); |
| 61 | 57 | const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; | |
| 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; | ||
| 66 | 58 | ||
| 67 | const openInEditor = useCallback( | 59 | const openInEditor = useCallback( |
| 68 | (editorId: EditorId | null) => { | 60 | (editorId: EditorId | null) => { |
| 69 | const api = readNativeApi(); | 61 | const api = readNativeApi(); |
| 70 | if (!api || !openInCwd) return; | 62 | if (!api || !openInCwd) return; |
| 71 | const editor = editorId ?? effectiveEditor; | 63 | const editor = editorId ?? preferredEditor; |
| 72 | if (!editor) return; | 64 | if (!editor) return; |
| 73 | void api.shell.openInEditor(openInCwd, editor); | 65 | void api.shell.openInEditor(openInCwd, editor); |
| 74 | localStorage.setItem(LAST_EDITOR_KEY, editor); | 66 | setPreferredEditor(editor); |
| 75 | setLastEditor(editor); | ||
| 76 | }, | 67 | }, |
| 77 | [effectiveEditor, openInCwd, setLastEditor], | 68 | [preferredEditor, openInCwd, setPreferredEditor], |
| 78 | ); | 69 | ); |
| 79 | 70 | ||
| 80 | const openFavoriteEditorShortcutLabel = useMemo( | 71 | const openFavoriteEditorShortcutLabel = useMemo( |
| ... | @@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 87 | const api = readNativeApi(); | 78 | const api = readNativeApi(); |
| 88 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; | 79 | if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; |
| 89 | if (!api || !openInCwd) return; | 80 | if (!api || !openInCwd) return; |
| 90 | if (!effectiveEditor) return; | 81 | if (!preferredEditor) return; |
| 91 | 82 | ||
| 92 | e.preventDefault(); | 83 | e.preventDefault(); |
| 93 | void api.shell.openInEditor(openInCwd, effectiveEditor); | 84 | void api.shell.openInEditor(openInCwd, preferredEditor); |
| 94 | }; | 85 | }; |
| 95 | window.addEventListener("keydown", handler); | 86 | window.addEventListener("keydown", handler); |
| 96 | return () => window.removeEventListener("keydown", handler); | 87 | return () => window.removeEventListener("keydown", handler); |
| 97 | }, [effectiveEditor, keybindings, openInCwd]); | 88 | }, [preferredEditor, keybindings, openInCwd]); |
| 98 | 89 | ||
| 99 | return ( | 90 | return ( |
| 100 | <Group aria-label="Subscription actions"> | 91 | <Group aria-label="Subscription actions"> |
| 101 | <Button | 92 | <Button |
| 102 | size="xs" | 93 | size="xs" |
| 103 | variant="outline" | 94 | variant="outline" |
| 104 | disabled={!effectiveEditor || !openInCwd} | 95 | disabled={!preferredEditor || !openInCwd} |
| 105 | onClick={() => openInEditor(effectiveEditor)} | 96 | onClick={() => openInEditor(preferredEditor)} |
| 106 | > | 97 | > |
| 107 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} | 98 | {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />} |
| 108 | <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5"> | 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,7 +111,7 @@ export const OpenInPicker = memo(function OpenInPicker({ |
| 120 | <MenuItem key={value} onClick={() => openInEditor(value)}> | 111 | <MenuItem key={value} onClick={() => openInEditor(value)}> |
| 121 | <Icon aria-hidden="true" className="text-muted-foreground" /> | 112 | <Icon aria-hidden="true" className="text-muted-foreground" /> |
| 122 | {label} | 113 | {label} |
| 123 | {value === effectiveEditor && openFavoriteEditorShortcutLabel && ( | 114 | {value === preferredEditor && openFavoriteEditorShortcutLabel && ( |
| 124 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> | 115 | <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut> |
| 125 | )} | 116 | )} |
| 126 | </MenuItem> | 117 | </MenuItem> |
apps/web/src/components/ui/sidebar.tsx+5-3| ... | @@ -18,6 +18,8 @@ import { | ... | @@ -18,6 +18,8 @@ import { |
| 18 | import { Skeleton } from "~/components/ui/skeleton"; | 18 | import { Skeleton } from "~/components/ui/skeleton"; |
| 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 20 | import { useMediaQuery } from "~/hooks/useMediaQuery"; | 20 | import { useMediaQuery } from "~/hooks/useMediaQuery"; |
| 21 | import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; | ||
| 22 | import { Schema } from "effect"; | ||
| 21 | 23 | ||
| 22 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; | 24 | const SIDEBAR_COOKIE_NAME = "sidebar_state"; |
| 23 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; | 25 | const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; |
| ... | @@ -371,7 +373,7 @@ function SidebarRail({ | ... | @@ -371,7 +373,7 @@ function SidebarRail({ |
| 371 | element.style.removeProperty("transition-duration"); | 373 | element.style.removeProperty("transition-duration"); |
| 372 | }); | 374 | }); |
| 373 | if (resolvedResizable?.storageKey && typeof window !== "undefined") { | 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 | resolvedResizable?.onResize?.(resizeState.width); | 378 | resolvedResizable?.onResize?.(resizeState.width); |
| 377 | resizeStateRef.current = null; | 379 | resizeStateRef.current = null; |
| ... | @@ -541,8 +543,8 @@ function SidebarRail({ | ... | @@ -541,8 +543,8 @@ function SidebarRail({ |
| 541 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); | 543 | const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']"); |
| 542 | if (!wrapper) return; | 544 | if (!wrapper) return; |
| 543 | 545 | ||
| 544 | const storedWidth = Number(window.localStorage.getItem(resolvedResizable.storageKey)); | 546 | const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite); |
| 545 | if (!Number.isFinite(storedWidth)) return; | 547 | if (storedWidth === null) return; |
| 546 | const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); | 548 | const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable); |
| 547 | wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); | 549 | wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`); |
| 548 | resolvedResizable.onResize?.(clampedWidth); | 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,12 +13,12 @@ import { Throttler } from "@tanstack/react-pacer"; |
| 13 | import { APP_DISPLAY_NAME } from "../branding"; | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { Button } from "../components/ui/button"; | 14 | import { Button } from "../components/ui/button"; |
| 15 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; | 15 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 16 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | ||
| 16 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; | 17 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; |
| 17 | import { readNativeApi } from "../nativeApi"; | 18 | import { readNativeApi } from "../nativeApi"; |
| 18 | import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; | 19 | import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; |
| 19 | import { useStore } from "../store"; | 20 | import { useStore } from "../store"; |
| 20 | import { useTerminalStateStore } from "../terminalStateStore"; | 21 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 21 | import { preferredTerminalEditor } from "../terminal-links"; | ||
| 22 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; | 22 | import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; |
| 23 | import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; | 23 | import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; |
| 24 | import { providerQueryKeys } from "../lib/providerReactQuery"; | 24 | import { providerQueryKeys } from "../lib/providerReactQuery"; |
| ... | @@ -281,9 +281,13 @@ function EventRouter() { | ... | @@ -281,9 +281,13 @@ function EventRouter() { |
| 281 | onClick: () => { | 281 | onClick: () => { |
| 282 | void queryClient | 282 | void queryClient |
| 283 | .ensureQueryData(serverConfigQueryOptions()) | 283 | .ensureQueryData(serverConfigQueryOptions()) |
| 284 | .then((config) => | 284 | .then((config) => { |
| 285 | api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()), | 285 | const editor = resolveAndPersistPreferredEditor(config.availableEditors); |
| 286 | ) | 286 | if (!editor) { |
| 287 | throw new Error("No available editors found."); | ||
| 288 | } | ||
| 289 | return api.shell.openInEditor(config.keybindingsConfigPath, editor); | ||
| 290 | }) | ||
| 287 | .catch((error) => { | 291 | .catch((error) => { |
| 288 | toastManager.add({ | 292 | toastManager.add({ |
| 289 | type: "error", | 293 | type: "error", |
apps/web/src/routes/_chat.settings.tsx+10-4| ... | @@ -3,13 +3,12 @@ import { useQuery } from "@tanstack/react-query"; | ... | @@ -3,13 +3,12 @@ import { useQuery } from "@tanstack/react-query"; |
| 3 | import { useCallback, useState } from "react"; | 3 | import { useCallback, useState } from "react"; |
| 4 | import { type ProviderKind } from "@t3tools/contracts"; | 4 | import { type ProviderKind } from "@t3tools/contracts"; |
| 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 6 | |||
| 7 | import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; | 6 | import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; |
| 7 | import { resolveAndPersistPreferredEditor } from "../editorPreferences"; | ||
| 8 | import { isElectron } from "../env"; | 8 | import { isElectron } from "../env"; |
| 9 | import { useTheme } from "../hooks/useTheme"; | 9 | import { useTheme } from "../hooks/useTheme"; |
| 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 11 | import { ensureNativeApi } from "../nativeApi"; | 11 | import { ensureNativeApi } from "../nativeApi"; |
| 12 | import { preferredTerminalEditor } from "../terminal-links"; | ||
| 13 | import { Button } from "../components/ui/button"; | 12 | import { Button } from "../components/ui/button"; |
| 14 | import { Input } from "../components/ui/input"; | 13 | import { Input } from "../components/ui/input"; |
| 15 | import { Switch } from "../components/ui/switch"; | 14 | import { Switch } from "../components/ui/switch"; |
| ... | @@ -98,14 +97,21 @@ function SettingsRouteView() { | ... | @@ -98,14 +97,21 @@ function SettingsRouteView() { |
| 98 | const codexBinaryPath = settings.codexBinaryPath; | 97 | const codexBinaryPath = settings.codexBinaryPath; |
| 99 | const codexHomePath = settings.codexHomePath; | 98 | const codexHomePath = settings.codexHomePath; |
| 100 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; | 99 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 100 | const availableEditors = serverConfigQuery.data?.availableEditors; | ||
| 101 | 101 | ||
| 102 | const openKeybindingsFile = useCallback(() => { | 102 | const openKeybindingsFile = useCallback(() => { |
| 103 | if (!keybindingsConfigPath) return; | 103 | if (!keybindingsConfigPath) return; |
| 104 | setOpenKeybindingsError(null); | 104 | setOpenKeybindingsError(null); |
| 105 | setIsOpeningKeybindings(true); | 105 | setIsOpeningKeybindings(true); |
| 106 | const api = ensureNativeApi(); | 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 | void api.shell | 113 | void api.shell |
| 108 | .openInEditor(keybindingsConfigPath, preferredTerminalEditor()) | 114 | .openInEditor(keybindingsConfigPath, editor) |
| 109 | .catch((error) => { | 115 | .catch((error) => { |
| 110 | setOpenKeybindingsError( | 116 | setOpenKeybindingsError( |
| 111 | error instanceof Error ? error.message : "Unable to open keybindings file.", | 117 | error instanceof Error ? error.message : "Unable to open keybindings file.", |
| ... | @@ -114,7 +120,7 @@ function SettingsRouteView() { | ... | @@ -114,7 +120,7 @@ function SettingsRouteView() { |
| 114 | .finally(() => { | 120 | .finally(() => { |
| 115 | setIsOpeningKeybindings(false); | 121 | setIsOpeningKeybindings(false); |
| 116 | }); | 122 | }); |
| 117 | }, [keybindingsConfigPath]); | 123 | }, [availableEditors, keybindingsConfigPath]); |
| 118 | 124 | ||
| 119 | const addCustomModel = useCallback( | 125 | const addCustomModel = useCallback( |
| 120 | (provider: ProviderKind) => { | 126 | (provider: ProviderKind) => { |
apps/web/src/terminal-links.ts-22| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | ||
| 2 | import { isMacPlatform } from "./lib/utils"; | 1 | import { isMacPlatform } from "./lib/utils"; |
| 3 | 2 | ||
| 4 | export type TerminalLinkKind = "url" | "path"; | 3 | export type TerminalLinkKind = "url" | "path"; |
| ... | @@ -14,7 +13,6 @@ const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; | ... | @@ -14,7 +13,6 @@ const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; |
| 14 | const FILE_PATH_PATTERN = | 13 | const FILE_PATH_PATTERN = |
| 15 | /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; | 14 | /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; |
| 16 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; | 15 | const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; |
| 17 | const LAST_EDITOR_KEY = "t3code:last-editor"; | ||
| 18 | 16 | ||
| 19 | function trimClosingDelimiters(value: string): string { | 17 | function trimClosingDelimiters(value: string): string { |
| 20 | let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); | 18 | let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); |
| ... | @@ -175,23 +173,3 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { | ... | @@ -175,23 +173,3 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { |
| 175 | if (!line) return resolvedPath; | 173 | if (!line) return resolvedPath; |
| 176 | return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; | 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 | } |