kanna-code 0.35.2 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BIJ8Xw2U.css +32 -0
- package/dist/client/assets/index-j8t9Uev7.js +2613 -0
- package/dist/client/editor-icons/default-app.png +0 -0
- package/dist/client/editor-icons/preview.png +0 -0
- package/dist/client/index.html +3 -8
- package/dist/export-viewer/assets/index-Cghqxose.js +410 -0
- package/dist/export-viewer/assets/index-D3nRTJ38.css +1 -0
- package/dist/export-viewer/editor-icons/default-app.png +0 -0
- package/dist/export-viewer/editor-icons/preview.png +0 -0
- package/dist/export-viewer/index.html +2 -2
- package/package.json +1 -1
- package/src/server/analytics.ts +9 -4
- package/src/server/app-settings.test.ts +87 -15
- package/src/server/app-settings.ts +304 -73
- package/src/server/external-open.test.ts +37 -1
- package/src/server/external-open.ts +72 -1
- package/src/server/ws-router.test.ts +151 -0
- package/src/server/ws-router.ts +133 -13
- package/src/shared/protocol.ts +8 -2
- package/src/shared/types.ts +45 -0
- package/dist/client/assets/index-BfaPrPZL.js +0 -2613
- package/dist/client/assets/index-DMZaICf7.css +0 -32
- package/dist/export-viewer/assets/index-CGKmJfo4.css +0 -1
- package/dist/export-viewer/assets/index-Cl-AwKng.js +0 -400
|
@@ -4,11 +4,48 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
|
4
4
|
import { homedir } from "node:os"
|
|
5
5
|
import path from "node:path"
|
|
6
6
|
import { getSettingsFilePath, LOG_PREFIX } from "../shared/branding"
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_CLAUDE_MODEL_OPTIONS,
|
|
9
|
+
DEFAULT_CODEX_MODEL_OPTIONS,
|
|
10
|
+
isClaudeReasoningEffort,
|
|
11
|
+
isCodexReasoningEffort,
|
|
12
|
+
normalizeClaudeContextWindow,
|
|
13
|
+
normalizeClaudeModelId,
|
|
14
|
+
normalizeCodexModelId,
|
|
15
|
+
supportsClaudeMaxReasoningEffort,
|
|
16
|
+
type AppSettingsPatch,
|
|
17
|
+
type AppSettingsSnapshot,
|
|
18
|
+
type AppThemePreference,
|
|
19
|
+
type ChatProviderPreferences,
|
|
20
|
+
type ChatSoundId,
|
|
21
|
+
type ChatSoundPreference,
|
|
22
|
+
type ClaudeModelOptions,
|
|
23
|
+
type CodexModelOptions,
|
|
24
|
+
type DefaultProviderPreference,
|
|
25
|
+
type EditorPreset,
|
|
26
|
+
type ProviderPreference,
|
|
27
|
+
} from "../shared/types"
|
|
8
28
|
|
|
9
29
|
interface AppSettingsFile {
|
|
10
30
|
analyticsEnabled?: unknown
|
|
11
31
|
analyticsUserId?: unknown
|
|
32
|
+
browserSettingsMigrated?: unknown
|
|
33
|
+
theme?: unknown
|
|
34
|
+
chatSoundPreference?: unknown
|
|
35
|
+
chatSoundId?: unknown
|
|
36
|
+
terminal?: {
|
|
37
|
+
scrollbackLines?: unknown
|
|
38
|
+
minColumnWidth?: unknown
|
|
39
|
+
}
|
|
40
|
+
editor?: {
|
|
41
|
+
preset?: unknown
|
|
42
|
+
commandTemplate?: unknown
|
|
43
|
+
}
|
|
44
|
+
defaultProvider?: unknown
|
|
45
|
+
providerDefaults?: {
|
|
46
|
+
claude?: Partial<ProviderPreference<Partial<ClaudeModelOptions>>> & { effort?: unknown }
|
|
47
|
+
codex?: Partial<ProviderPreference<Partial<CodexModelOptions>>> & { effort?: unknown }
|
|
48
|
+
}
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
interface AppSettingsState extends AppSettingsSnapshot {
|
|
@@ -16,14 +53,21 @@ interface AppSettingsState extends AppSettingsSnapshot {
|
|
|
16
53
|
}
|
|
17
54
|
|
|
18
55
|
interface NormalizedAppSettings {
|
|
19
|
-
payload:
|
|
20
|
-
analyticsEnabled: boolean
|
|
21
|
-
analyticsUserId: string
|
|
22
|
-
}
|
|
56
|
+
payload: AppSettingsState
|
|
23
57
|
warning: string | null
|
|
24
58
|
shouldWrite: boolean
|
|
25
59
|
}
|
|
26
60
|
|
|
61
|
+
const DEFAULT_TERMINAL_SCROLLBACK = 1_000
|
|
62
|
+
const MIN_TERMINAL_SCROLLBACK = 500
|
|
63
|
+
const MAX_TERMINAL_SCROLLBACK = 5_000
|
|
64
|
+
const DEFAULT_TERMINAL_MIN_COLUMN_WIDTH = 450
|
|
65
|
+
const MIN_TERMINAL_MIN_COLUMN_WIDTH = 250
|
|
66
|
+
const MAX_TERMINAL_MIN_COLUMN_WIDTH = 900
|
|
67
|
+
const DEFAULT_EDITOR_PRESET: EditorPreset = "cursor"
|
|
68
|
+
const DEFAULT_CHAT_SOUND_PREFERENCE: ChatSoundPreference = "always"
|
|
69
|
+
const DEFAULT_CHAT_SOUND_ID: ChatSoundId = "funk"
|
|
70
|
+
|
|
27
71
|
function formatDisplayPath(filePath: string) {
|
|
28
72
|
const homePath = homedir()
|
|
29
73
|
if (filePath === homePath) return "~"
|
|
@@ -37,6 +81,168 @@ function createAnalyticsUserId() {
|
|
|
37
81
|
return `anon_${randomUUID()}`
|
|
38
82
|
}
|
|
39
83
|
|
|
84
|
+
function getDefaultEditorCommandTemplate(preset: EditorPreset) {
|
|
85
|
+
switch (preset) {
|
|
86
|
+
case "vscode":
|
|
87
|
+
return "code {path}"
|
|
88
|
+
case "xcode":
|
|
89
|
+
return "xed {path}"
|
|
90
|
+
case "windsurf":
|
|
91
|
+
return "windsurf {path}"
|
|
92
|
+
case "custom":
|
|
93
|
+
case "cursor":
|
|
94
|
+
default:
|
|
95
|
+
return "cursor {path}"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createDefaultProviderDefaults(): ChatProviderPreferences {
|
|
100
|
+
return {
|
|
101
|
+
claude: {
|
|
102
|
+
model: "claude-opus-4-7",
|
|
103
|
+
modelOptions: { ...DEFAULT_CLAUDE_MODEL_OPTIONS },
|
|
104
|
+
planMode: false,
|
|
105
|
+
},
|
|
106
|
+
codex: {
|
|
107
|
+
model: "gpt-5.5",
|
|
108
|
+
modelOptions: { ...DEFAULT_CODEX_MODEL_OPTIONS },
|
|
109
|
+
planMode: false,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function clampNumber(value: unknown, fallback: number, min: number, max: number) {
|
|
115
|
+
const numberValue = typeof value === "number" ? value : Number(value)
|
|
116
|
+
if (!Number.isFinite(numberValue)) return fallback
|
|
117
|
+
return Math.min(max, Math.max(min, Math.round(numberValue)))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeTheme(value: unknown): AppThemePreference {
|
|
121
|
+
return value === "light" || value === "dark" || value === "system" ? value : "system"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeChatSoundPreference(value: unknown): ChatSoundPreference {
|
|
125
|
+
return value === "never" || value === "unfocused" || value === "always" ? value : DEFAULT_CHAT_SOUND_PREFERENCE
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeChatSoundId(value: unknown): ChatSoundId {
|
|
129
|
+
switch (value) {
|
|
130
|
+
case "blow":
|
|
131
|
+
case "bottle":
|
|
132
|
+
case "frog":
|
|
133
|
+
case "funk":
|
|
134
|
+
case "glass":
|
|
135
|
+
case "ping":
|
|
136
|
+
case "pop":
|
|
137
|
+
case "purr":
|
|
138
|
+
case "tink":
|
|
139
|
+
return value
|
|
140
|
+
default:
|
|
141
|
+
return DEFAULT_CHAT_SOUND_ID
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeDefaultProvider(value: unknown): DefaultProviderPreference {
|
|
146
|
+
return value === "claude" || value === "codex" || value === "last_used" ? value : "last_used"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeEditorPreset(value: unknown): EditorPreset {
|
|
150
|
+
return value === "vscode" || value === "xcode" || value === "windsurf" || value === "custom" || value === "cursor"
|
|
151
|
+
? value
|
|
152
|
+
: DEFAULT_EDITOR_PRESET
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeEditorCommandTemplate(value: unknown, preset: EditorPreset) {
|
|
156
|
+
const trimmed = typeof value === "string" ? value.trim() : ""
|
|
157
|
+
return trimmed || getDefaultEditorCommandTemplate(preset)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function normalizeClaudePreference(value?: {
|
|
161
|
+
model?: unknown
|
|
162
|
+
effort?: unknown
|
|
163
|
+
modelOptions?: Partial<Record<keyof ClaudeModelOptions, unknown>>
|
|
164
|
+
planMode?: unknown
|
|
165
|
+
}): ProviderPreference<ClaudeModelOptions> {
|
|
166
|
+
const model = normalizeClaudeModelId(typeof value?.model === "string" ? value.model : undefined)
|
|
167
|
+
const reasoningEffort = value?.modelOptions?.reasoningEffort
|
|
168
|
+
const normalizedEffort = isClaudeReasoningEffort(reasoningEffort)
|
|
169
|
+
? reasoningEffort
|
|
170
|
+
: isClaudeReasoningEffort(value?.effort)
|
|
171
|
+
? value.effort
|
|
172
|
+
: DEFAULT_CLAUDE_MODEL_OPTIONS.reasoningEffort
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
model,
|
|
176
|
+
modelOptions: {
|
|
177
|
+
reasoningEffort: !supportsClaudeMaxReasoningEffort(model) && normalizedEffort === "max" ? "high" : normalizedEffort,
|
|
178
|
+
contextWindow: normalizeClaudeContextWindow(model, value?.modelOptions?.contextWindow),
|
|
179
|
+
},
|
|
180
|
+
planMode: value?.planMode === true,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeCodexPreference(value?: {
|
|
185
|
+
model?: unknown
|
|
186
|
+
effort?: unknown
|
|
187
|
+
modelOptions?: Partial<Record<keyof CodexModelOptions, unknown>>
|
|
188
|
+
planMode?: unknown
|
|
189
|
+
}): ProviderPreference<CodexModelOptions> {
|
|
190
|
+
const reasoningEffort = value?.modelOptions?.reasoningEffort
|
|
191
|
+
return {
|
|
192
|
+
model: normalizeCodexModelId(typeof value?.model === "string" ? value.model : undefined),
|
|
193
|
+
modelOptions: {
|
|
194
|
+
reasoningEffort: isCodexReasoningEffort(reasoningEffort)
|
|
195
|
+
? reasoningEffort
|
|
196
|
+
: isCodexReasoningEffort(value?.effort)
|
|
197
|
+
? value.effort
|
|
198
|
+
: DEFAULT_CODEX_MODEL_OPTIONS.reasoningEffort,
|
|
199
|
+
fastMode: typeof value?.modelOptions?.fastMode === "boolean"
|
|
200
|
+
? value.modelOptions.fastMode
|
|
201
|
+
: DEFAULT_CODEX_MODEL_OPTIONS.fastMode,
|
|
202
|
+
},
|
|
203
|
+
planMode: value?.planMode === true,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeProviderDefaults(value: AppSettingsFile["providerDefaults"] | undefined): ChatProviderPreferences {
|
|
208
|
+
const defaults = createDefaultProviderDefaults()
|
|
209
|
+
return {
|
|
210
|
+
claude: normalizeClaudePreference(value?.claude ?? defaults.claude),
|
|
211
|
+
codex: normalizeCodexPreference(value?.codex ?? defaults.codex),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function toFilePayload(state: AppSettingsState) {
|
|
216
|
+
return {
|
|
217
|
+
analyticsEnabled: state.analyticsEnabled,
|
|
218
|
+
analyticsUserId: state.analyticsUserId,
|
|
219
|
+
browserSettingsMigrated: state.browserSettingsMigrated,
|
|
220
|
+
theme: state.theme,
|
|
221
|
+
chatSoundPreference: state.chatSoundPreference,
|
|
222
|
+
chatSoundId: state.chatSoundId,
|
|
223
|
+
terminal: state.terminal,
|
|
224
|
+
editor: state.editor,
|
|
225
|
+
defaultProvider: state.defaultProvider,
|
|
226
|
+
providerDefaults: state.providerDefaults,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toSnapshot(state: AppSettingsState): AppSettingsSnapshot {
|
|
231
|
+
return {
|
|
232
|
+
analyticsEnabled: state.analyticsEnabled,
|
|
233
|
+
browserSettingsMigrated: state.browserSettingsMigrated,
|
|
234
|
+
theme: state.theme,
|
|
235
|
+
chatSoundPreference: state.chatSoundPreference,
|
|
236
|
+
chatSoundId: state.chatSoundId,
|
|
237
|
+
terminal: state.terminal,
|
|
238
|
+
editor: state.editor,
|
|
239
|
+
defaultProvider: state.defaultProvider,
|
|
240
|
+
providerDefaults: state.providerDefaults,
|
|
241
|
+
warning: state.warning,
|
|
242
|
+
filePathDisplay: state.filePathDisplay,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
40
246
|
function normalizeAppSettings(
|
|
41
247
|
value: unknown,
|
|
42
248
|
filePath = getSettingsFilePath(homedir())
|
|
@@ -50,80 +256,122 @@ function normalizeAppSettings(
|
|
|
50
256
|
warnings.push("Settings file must contain a JSON object")
|
|
51
257
|
}
|
|
52
258
|
|
|
53
|
-
const analyticsEnabled = typeof source?.analyticsEnabled === "boolean"
|
|
54
|
-
? source.analyticsEnabled
|
|
55
|
-
: true
|
|
259
|
+
const analyticsEnabled = typeof source?.analyticsEnabled === "boolean" ? source.analyticsEnabled : true
|
|
56
260
|
if (source?.analyticsEnabled !== undefined && typeof source.analyticsEnabled !== "boolean") {
|
|
57
261
|
warnings.push("analyticsEnabled must be a boolean")
|
|
58
262
|
}
|
|
59
263
|
|
|
60
|
-
const rawAnalyticsUserId = typeof source?.analyticsUserId === "string"
|
|
61
|
-
? source.analyticsUserId.trim()
|
|
62
|
-
: ""
|
|
264
|
+
const rawAnalyticsUserId = typeof source?.analyticsUserId === "string" ? source.analyticsUserId.trim() : ""
|
|
63
265
|
if (source?.analyticsUserId !== undefined && typeof source.analyticsUserId !== "string") {
|
|
64
266
|
warnings.push("analyticsUserId must be a string")
|
|
65
267
|
}
|
|
66
|
-
|
|
67
268
|
const analyticsUserId = rawAnalyticsUserId || createAnalyticsUserId()
|
|
68
269
|
if (!rawAnalyticsUserId && source?.analyticsUserId !== undefined) {
|
|
69
270
|
warnings.push("analyticsUserId must be a non-empty string")
|
|
70
271
|
}
|
|
71
272
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
273
|
+
const editorPreset = normalizeEditorPreset(source?.editor?.preset)
|
|
274
|
+
const state: AppSettingsState = {
|
|
275
|
+
analyticsEnabled,
|
|
276
|
+
analyticsUserId,
|
|
277
|
+
browserSettingsMigrated: source?.browserSettingsMigrated === true,
|
|
278
|
+
theme: normalizeTheme(source?.theme),
|
|
279
|
+
chatSoundPreference: normalizeChatSoundPreference(source?.chatSoundPreference),
|
|
280
|
+
chatSoundId: normalizeChatSoundId(source?.chatSoundId),
|
|
281
|
+
terminal: {
|
|
282
|
+
scrollbackLines: clampNumber(source?.terminal?.scrollbackLines, DEFAULT_TERMINAL_SCROLLBACK, MIN_TERMINAL_SCROLLBACK, MAX_TERMINAL_SCROLLBACK),
|
|
283
|
+
minColumnWidth: clampNumber(source?.terminal?.minColumnWidth, DEFAULT_TERMINAL_MIN_COLUMN_WIDTH, MIN_TERMINAL_MIN_COLUMN_WIDTH, MAX_TERMINAL_MIN_COLUMN_WIDTH),
|
|
284
|
+
},
|
|
285
|
+
editor: {
|
|
286
|
+
preset: editorPreset,
|
|
287
|
+
commandTemplate: normalizeEditorCommandTemplate(source?.editor?.commandTemplate, editorPreset),
|
|
288
|
+
},
|
|
289
|
+
defaultProvider: normalizeDefaultProvider(source?.defaultProvider),
|
|
290
|
+
providerDefaults: normalizeProviderDefaults(source?.providerDefaults),
|
|
291
|
+
warning: null,
|
|
292
|
+
filePathDisplay: formatDisplayPath(filePath),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const shouldWrite = JSON.stringify(source ? toComparablePayload(source) : null) !== JSON.stringify(toFilePayload(state))
|
|
296
|
+
state.warning = warnings.length > 0
|
|
297
|
+
? `Some settings were reset to defaults: ${warnings.join("; ")}`
|
|
298
|
+
: null
|
|
75
299
|
|
|
76
300
|
return {
|
|
77
|
-
payload:
|
|
78
|
-
|
|
79
|
-
analyticsUserId,
|
|
80
|
-
},
|
|
81
|
-
warning: warnings.length > 0
|
|
82
|
-
? `Some settings were reset to defaults: ${warnings.join("; ")}`
|
|
83
|
-
: null,
|
|
301
|
+
payload: state,
|
|
302
|
+
warning: state.warning,
|
|
84
303
|
shouldWrite,
|
|
85
304
|
}
|
|
86
305
|
}
|
|
87
306
|
|
|
88
|
-
function
|
|
307
|
+
function toComparablePayload(source: AppSettingsFile) {
|
|
89
308
|
return {
|
|
90
|
-
analyticsEnabled:
|
|
91
|
-
|
|
92
|
-
|
|
309
|
+
analyticsEnabled: source.analyticsEnabled,
|
|
310
|
+
analyticsUserId: typeof source.analyticsUserId === "string" ? source.analyticsUserId.trim() : source.analyticsUserId,
|
|
311
|
+
browserSettingsMigrated: source.browserSettingsMigrated,
|
|
312
|
+
theme: source.theme,
|
|
313
|
+
chatSoundPreference: source.chatSoundPreference,
|
|
314
|
+
chatSoundId: source.chatSoundId,
|
|
315
|
+
terminal: source.terminal,
|
|
316
|
+
editor: source.editor,
|
|
317
|
+
defaultProvider: source.defaultProvider,
|
|
318
|
+
providerDefaults: source.providerDefaults,
|
|
93
319
|
}
|
|
94
320
|
}
|
|
95
321
|
|
|
322
|
+
function applyPatch(state: AppSettingsState, patch: AppSettingsPatch): AppSettingsState {
|
|
323
|
+
return normalizeAppSettings({
|
|
324
|
+
...toFilePayload(state),
|
|
325
|
+
...patch,
|
|
326
|
+
terminal: {
|
|
327
|
+
...state.terminal,
|
|
328
|
+
...patch.terminal,
|
|
329
|
+
},
|
|
330
|
+
editor: {
|
|
331
|
+
...state.editor,
|
|
332
|
+
...patch.editor,
|
|
333
|
+
},
|
|
334
|
+
providerDefaults: {
|
|
335
|
+
claude: {
|
|
336
|
+
...state.providerDefaults.claude,
|
|
337
|
+
...patch.providerDefaults?.claude,
|
|
338
|
+
modelOptions: {
|
|
339
|
+
...state.providerDefaults.claude.modelOptions,
|
|
340
|
+
...patch.providerDefaults?.claude?.modelOptions,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
codex: {
|
|
344
|
+
...state.providerDefaults.codex,
|
|
345
|
+
...patch.providerDefaults?.codex,
|
|
346
|
+
modelOptions: {
|
|
347
|
+
...state.providerDefaults.codex.modelOptions,
|
|
348
|
+
...patch.providerDefaults?.codex?.modelOptions,
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}, state.filePathDisplay).payload
|
|
353
|
+
}
|
|
354
|
+
|
|
96
355
|
export async function readAppSettingsSnapshot(filePath = getSettingsFilePath(homedir())) {
|
|
97
356
|
try {
|
|
98
357
|
const text = await readFile(filePath, "utf8")
|
|
99
358
|
if (!text.trim()) {
|
|
100
359
|
const normalized = normalizeAppSettings(undefined, filePath)
|
|
101
360
|
return {
|
|
102
|
-
|
|
361
|
+
...toSnapshot(normalized.payload),
|
|
103
362
|
warning: "Settings file was empty. Using defaults.",
|
|
104
|
-
filePathDisplay: formatDisplayPath(filePath),
|
|
105
363
|
} satisfies AppSettingsSnapshot
|
|
106
364
|
}
|
|
107
365
|
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
analyticsEnabled: normalized.payload.analyticsEnabled,
|
|
111
|
-
warning: normalized.warning,
|
|
112
|
-
filePathDisplay: formatDisplayPath(filePath),
|
|
113
|
-
} satisfies AppSettingsSnapshot
|
|
366
|
+
return toSnapshot(normalizeAppSettings(JSON.parse(text), filePath).payload)
|
|
114
367
|
} catch (error) {
|
|
115
368
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
|
|
116
|
-
return
|
|
117
|
-
analyticsEnabled: true,
|
|
118
|
-
warning: null,
|
|
119
|
-
filePathDisplay: formatDisplayPath(filePath),
|
|
120
|
-
} satisfies AppSettingsSnapshot
|
|
369
|
+
return toSnapshot(normalizeAppSettings(undefined, filePath).payload)
|
|
121
370
|
}
|
|
122
371
|
if (error instanceof SyntaxError) {
|
|
123
372
|
return {
|
|
124
|
-
|
|
373
|
+
...toSnapshot(normalizeAppSettings(undefined, filePath).payload),
|
|
125
374
|
warning: "Settings file is invalid JSON. Using defaults.",
|
|
126
|
-
filePathDisplay: formatDisplayPath(filePath),
|
|
127
375
|
} satisfies AppSettingsSnapshot
|
|
128
376
|
}
|
|
129
377
|
throw error
|
|
@@ -138,13 +386,7 @@ export class AppSettingsManager {
|
|
|
138
386
|
|
|
139
387
|
constructor(filePath = getSettingsFilePath(homedir())) {
|
|
140
388
|
this.filePath = filePath
|
|
141
|
-
|
|
142
|
-
this.state = {
|
|
143
|
-
analyticsEnabled: true,
|
|
144
|
-
analyticsUserId: createAnalyticsUserId(),
|
|
145
|
-
warning: null,
|
|
146
|
-
filePathDisplay: displayPath,
|
|
147
|
-
}
|
|
389
|
+
this.state = normalizeAppSettings(undefined, filePath).payload
|
|
148
390
|
}
|
|
149
391
|
|
|
150
392
|
async initialize() {
|
|
@@ -180,58 +422,47 @@ export class AppSettingsManager {
|
|
|
180
422
|
}
|
|
181
423
|
|
|
182
424
|
async write(value: { analyticsEnabled: boolean }) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const nextState: AppSettingsState = {
|
|
190
|
-
analyticsEnabled: payload.analyticsEnabled,
|
|
191
|
-
analyticsUserId: payload.analyticsUserId,
|
|
425
|
+
return this.writePatch({ analyticsEnabled: value.analyticsEnabled })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async writePatch(patch: AppSettingsPatch) {
|
|
429
|
+
const nextState = {
|
|
430
|
+
...applyPatch(this.state, patch),
|
|
192
431
|
warning: null,
|
|
193
432
|
filePathDisplay: formatDisplayPath(this.filePath),
|
|
194
433
|
}
|
|
434
|
+
await mkdir(path.dirname(this.filePath), { recursive: true })
|
|
435
|
+
await writeFile(this.filePath, `${JSON.stringify(toFilePayload(nextState), null, 2)}\n`, "utf8")
|
|
195
436
|
this.setState(nextState)
|
|
196
437
|
return toSnapshot(nextState)
|
|
197
438
|
}
|
|
198
439
|
|
|
199
440
|
private async readState(options?: { persistNormalized?: boolean }) {
|
|
200
441
|
const file = Bun.file(this.filePath)
|
|
201
|
-
const displayPath = formatDisplayPath(this.filePath)
|
|
202
442
|
|
|
203
443
|
try {
|
|
204
444
|
const text = await file.text()
|
|
205
445
|
const hasText = text.trim().length > 0
|
|
206
446
|
const normalized = normalizeAppSettings(hasText ? JSON.parse(text) : undefined, this.filePath)
|
|
207
447
|
if (options?.persistNormalized && (!hasText || normalized.shouldWrite)) {
|
|
208
|
-
await writeFile(this.filePath, `${JSON.stringify(normalized.payload, null, 2)}\n`, "utf8")
|
|
448
|
+
await writeFile(this.filePath, `${JSON.stringify(toFilePayload(normalized.payload), null, 2)}\n`, "utf8")
|
|
209
449
|
}
|
|
210
450
|
return {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
warning: !hasText
|
|
214
|
-
? "Settings file was empty. Using defaults."
|
|
215
|
-
: normalized.warning,
|
|
216
|
-
filePathDisplay: displayPath,
|
|
451
|
+
...normalized.payload,
|
|
452
|
+
warning: !hasText ? "Settings file was empty. Using defaults." : normalized.warning,
|
|
217
453
|
} satisfies AppSettingsState
|
|
218
454
|
} catch (error) {
|
|
219
455
|
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT" && !(error instanceof SyntaxError)) {
|
|
220
456
|
throw error
|
|
221
457
|
}
|
|
222
458
|
|
|
223
|
-
const warning = error instanceof SyntaxError
|
|
224
|
-
? "Settings file is invalid JSON. Using defaults."
|
|
225
|
-
: null
|
|
226
459
|
const normalized = normalizeAppSettings(undefined, this.filePath)
|
|
227
460
|
if (options?.persistNormalized) {
|
|
228
|
-
await writeFile(this.filePath, `${JSON.stringify(normalized.payload, null, 2)}\n`, "utf8")
|
|
461
|
+
await writeFile(this.filePath, `${JSON.stringify(toFilePayload(normalized.payload), null, 2)}\n`, "utf8")
|
|
229
462
|
}
|
|
230
463
|
return {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
warning,
|
|
234
|
-
filePathDisplay: displayPath,
|
|
464
|
+
...normalized.payload,
|
|
465
|
+
warning: error instanceof SyntaxError ? "Settings file is invalid JSON. Using defaults." : null,
|
|
235
466
|
} satisfies AppSettingsState
|
|
236
467
|
}
|
|
237
468
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { buildEditorCommand, tokenizeCommandTemplate } from "./external-open"
|
|
2
|
+
import { buildDefaultOpenCommand, buildEditorCommand, buildPreviewCommand, tokenizeCommandTemplate } from "./external-open"
|
|
3
3
|
|
|
4
4
|
describe("tokenizeCommandTemplate", () => {
|
|
5
5
|
test("keeps quoted arguments together", () => {
|
|
@@ -74,3 +74,39 @@ describe("buildEditorCommand", () => {
|
|
|
74
74
|
})
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
|
+
|
|
78
|
+
describe("buildPreviewCommand", () => {
|
|
79
|
+
test("builds a native macOS Preview open command", () => {
|
|
80
|
+
expect(
|
|
81
|
+
buildPreviewCommand({
|
|
82
|
+
localPath: "/Users/jake/Projects/kanna/mock.png",
|
|
83
|
+
isDirectory: false,
|
|
84
|
+
platform: "darwin",
|
|
85
|
+
})
|
|
86
|
+
).toEqual({
|
|
87
|
+
command: "open",
|
|
88
|
+
args: ["-a", "Preview", "/Users/jake/Projects/kanna/mock.png"],
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("rejects non-macOS platforms", () => {
|
|
93
|
+
expect(() => buildPreviewCommand({
|
|
94
|
+
localPath: "/Users/jake/Projects/kanna/mock.png",
|
|
95
|
+
isDirectory: false,
|
|
96
|
+
platform: "linux",
|
|
97
|
+
})).toThrow("Preview is only available on macOS")
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("buildDefaultOpenCommand", () => {
|
|
102
|
+
test("builds default open commands for supported platforms", () => {
|
|
103
|
+
expect(buildDefaultOpenCommand({ localPath: "/Users/jake/Projects/kanna/mock.png", platform: "darwin" })).toEqual({
|
|
104
|
+
command: "open",
|
|
105
|
+
args: ["/Users/jake/Projects/kanna/mock.png"],
|
|
106
|
+
})
|
|
107
|
+
expect(buildDefaultOpenCommand({ localPath: "/tmp/mock.png", platform: "linux" })).toEqual({
|
|
108
|
+
command: "xdg-open",
|
|
109
|
+
args: ["/tmp/mock.png"],
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -20,7 +20,7 @@ const DEFAULT_EDITOR_SETTINGS: EditorOpenSettings = {
|
|
|
20
20
|
export async function openExternal(command: OpenExternalCommand) {
|
|
21
21
|
const resolvedPath = resolveLocalPath(command.localPath)
|
|
22
22
|
const platform = process.platform
|
|
23
|
-
const info = command.action === "open_editor" || command.action === "open_finder"
|
|
23
|
+
const info = command.action === "open_editor" || command.action === "open_finder" || command.action === "open_preview" || command.action === "open_default"
|
|
24
24
|
? await stat(resolvedPath).catch(() => null)
|
|
25
25
|
: null
|
|
26
26
|
|
|
@@ -41,6 +41,29 @@ export async function openExternal(command: OpenExternalCommand) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
if (platform === "darwin") {
|
|
44
|
+
if (command.action === "open_default") {
|
|
45
|
+
if (!info) {
|
|
46
|
+
throw new Error(`Path not found: ${resolvedPath}`)
|
|
47
|
+
}
|
|
48
|
+
const defaultCommand = buildDefaultOpenCommand({ localPath: resolvedPath, platform })
|
|
49
|
+
await spawnDetached(defaultCommand.command, defaultCommand.args)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (command.action === "open_preview") {
|
|
53
|
+
if (!info) {
|
|
54
|
+
throw new Error(`Path not found: ${resolvedPath}`)
|
|
55
|
+
}
|
|
56
|
+
if (!canOpenMacApp("Preview")) {
|
|
57
|
+
throw new Error("Preview is not installed")
|
|
58
|
+
}
|
|
59
|
+
const previewCommand = buildPreviewCommand({
|
|
60
|
+
localPath: resolvedPath,
|
|
61
|
+
isDirectory: info.isDirectory(),
|
|
62
|
+
platform,
|
|
63
|
+
})
|
|
64
|
+
await spawnDetached(previewCommand.command, previewCommand.args)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
44
67
|
if (command.action === "open_finder") {
|
|
45
68
|
if (info?.isDirectory()) {
|
|
46
69
|
await spawnDetached("open", [resolvedPath])
|
|
@@ -59,6 +82,14 @@ export async function openExternal(command: OpenExternalCommand) {
|
|
|
59
82
|
}
|
|
60
83
|
|
|
61
84
|
if (platform === "win32") {
|
|
85
|
+
if (command.action === "open_default") {
|
|
86
|
+
if (!info) {
|
|
87
|
+
throw new Error(`Path not found: ${resolvedPath}`)
|
|
88
|
+
}
|
|
89
|
+
const defaultCommand = buildDefaultOpenCommand({ localPath: resolvedPath, platform })
|
|
90
|
+
await spawnDetached(defaultCommand.command, defaultCommand.args)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
62
93
|
if (command.action === "open_finder") {
|
|
63
94
|
if (info?.isDirectory()) {
|
|
64
95
|
await spawnDetached("explorer", [resolvedPath])
|
|
@@ -77,6 +108,19 @@ export async function openExternal(command: OpenExternalCommand) {
|
|
|
77
108
|
}
|
|
78
109
|
}
|
|
79
110
|
|
|
111
|
+
if (command.action === "open_preview") {
|
|
112
|
+
throw new Error("Preview is only available on macOS")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (command.action === "open_default") {
|
|
116
|
+
if (!info) {
|
|
117
|
+
throw new Error(`Path not found: ${resolvedPath}`)
|
|
118
|
+
}
|
|
119
|
+
const defaultCommand = buildDefaultOpenCommand({ localPath: resolvedPath, platform })
|
|
120
|
+
await spawnDetached(defaultCommand.command, defaultCommand.args)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
80
124
|
if (command.action === "open_finder") {
|
|
81
125
|
await spawnDetached("xdg-open", [info?.isDirectory() ? resolvedPath : path.dirname(resolvedPath)])
|
|
82
126
|
return
|
|
@@ -117,6 +161,33 @@ export function buildEditorCommand(args: {
|
|
|
117
161
|
return buildPresetEditorCommand(args, editor.preset)
|
|
118
162
|
}
|
|
119
163
|
|
|
164
|
+
export function buildPreviewCommand(args: {
|
|
165
|
+
localPath: string
|
|
166
|
+
isDirectory: boolean
|
|
167
|
+
platform: NodeJS.Platform
|
|
168
|
+
}): CommandSpec {
|
|
169
|
+
if (args.platform !== "darwin") {
|
|
170
|
+
throw new Error("Preview is only available on macOS")
|
|
171
|
+
}
|
|
172
|
+
if (args.isDirectory) {
|
|
173
|
+
throw new Error("Preview cannot open directories")
|
|
174
|
+
}
|
|
175
|
+
return { command: "open", args: ["-a", "Preview", args.localPath] }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildDefaultOpenCommand(args: {
|
|
179
|
+
localPath: string
|
|
180
|
+
platform: NodeJS.Platform
|
|
181
|
+
}): CommandSpec {
|
|
182
|
+
if (args.platform === "darwin") {
|
|
183
|
+
return { command: "open", args: [args.localPath] }
|
|
184
|
+
}
|
|
185
|
+
if (args.platform === "win32") {
|
|
186
|
+
return { command: "cmd", args: ["/c", "start", "", args.localPath] }
|
|
187
|
+
}
|
|
188
|
+
return { command: "xdg-open", args: [args.localPath] }
|
|
189
|
+
}
|
|
190
|
+
|
|
120
191
|
function buildPresetEditorCommand(
|
|
121
192
|
args: {
|
|
122
193
|
localPath: string
|