-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathGeneral.tsx
More file actions
323 lines (301 loc) · 12.2 KB
/
Copy pathGeneral.tsx
File metadata and controls
323 lines (301 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
import { Files, Info } from 'lucide-react'
import React, { useCallback, useState } from 'react'
import { Button } from '@/renderer/src/components/ui/button'
import { Input } from '@/renderer/src/components/ui/input'
import {
ToggleGroup,
ToggleGroupItem,
} from '@/renderer/src/components/ui/toggle-group'
import { useComponentEffect } from '@/renderer/src/hooks/useComponentEffect'
import { useUpdateSettings } from '@/renderer/src/hooks/useUpdateSettings'
import { useAppSelector } from '@/renderer/src/redux/hooks'
import { TERMINAL_APP_IDS, TERMINAL_APP_UI_LABELS } from '@/shared/constants'
import type { Settings } from '@/shared/settings'
import { SectionFrame, SectionRow } from './SectionFrame'
/**
* Default tab values mirror the `Settings['defaultSkillTab']` union.
* Both this control and `SkillDetail`'s tab buttons write to the same
* settings field — the most recently chosen tab is the default on next
* app open.
*/
const DEFAULT_TAB_OPTIONS: ReadonlyArray<{
value: Settings['defaultSkillTab']
label: string
icon: React.ComponentType<{ className?: string }>
}> = [
{ value: 'files', label: 'Files', icon: Files },
{ value: 'info', label: 'Info', icon: Info },
]
/**
* Length cap mirrors `SettingsSchema.customTerminalAppName.max(64)` and the
* IPC schema in `src/main/ipc/ipc-schemas.ts`. Surfacing the limit on the
* `<input maxLength>` keeps the user from typing past the boundary instead
* of getting a silent reject after blur.
*/
const CUSTOM_APP_NAME_MAX_LENGTH = 64
/**
* General settings pane.
*
* Currently ships:
* - "Default tab when opening a skill" (existing).
* - "Preferred terminal" — which macOS terminal "Open in Terminal" launches.
* `'custom'` reveals a free-form input that's persisted as
* `customTerminalAppName`.
*
* Dispatch flow on change (mirrors `defaultSkillTab`):
* 1. Local optimistic dispatch via `useUpdateSettings` for instant feedback.
* 2. IPC `settings:set` — main writes JSON atomically + broadcasts
* `settings:changed` to every window.
*
* The custom-app-name input intentionally does NOT fire IPC on every
* keystroke. We commit on `blur` (focus leaves the field) so an atomic
* disk write doesn't happen on each keypress — that would be a footgun
* for users typing slowly and would also fan out a `settings:changed`
* broadcast to every open window per character.
*/
export const General = React.memo(function General(): React.ReactElement {
const settings = useAppSelector((state) => state.settings)
const updateSettings = useUpdateSettings()
// Local mirror of the custom name field so users can type without
// committing on every keystroke. Initial value is whatever's in settings
// (which itself was last committed via blur). Reset implicitly on
// re-mount; explicit reset on settings change isn't needed because the
// field is only visible while preferredTerminal === 'custom'.
const [customNameDraft, setCustomNameDraft] = useState<string>(
settings.customTerminalAppName ?? '',
)
const handleDefaultTabChange = useCallback(
(nextValue: string): void => {
if (nextValue !== 'files' && nextValue !== 'info') return
updateSettings({ defaultSkillTab: nextValue })
},
[updateSettings],
)
const handlePreferredTerminalChange = (
e: React.ChangeEvent<HTMLSelectElement>,
): void => {
// `find` infers `TerminalAppId | undefined` from the readonly tuple, so
// the IPC dispatcher is narrowed without an `as` cast. The IPC schema
// (`z.enum(TERMINAL_APP_IDS)`) is the real trust boundary — this filter
// is only here so a tampered DOM can't tunnel a bad string in.
const next = TERMINAL_APP_IDS.find((id) => id === e.target.value)
if (!next) return
updateSettings({ preferredTerminal: next })
}
/**
* Commit the trimmed draft on blur. Empty string after trim → skip the
* IPC call (keeps prior value) so leaving the field blank doesn't wipe
* what the user previously saved. Same trim/min(1) guard as the Zod
* schema so the renderer never asks main to write a value that the
* schema would reject.
*/
const handleCustomNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
setCustomNameDraft(e.target.value)
},
[],
)
const handleCustomNameBlur = useCallback((): void => {
const trimmed = customNameDraft.trim()
if (trimmed === '') return
if (trimmed === settings.customTerminalAppName) return
updateSettings({ customTerminalAppName: trimmed })
}, [customNameDraft, settings.customTerminalAppName, updateSettings])
const isCustom = settings.preferredTerminal === 'custom'
const isDraftBlank = customNameDraft.trim() === ''
/**
* Tracks whether the main window is currently alive — Settings can
* outlive it on macOS (window-all-closed keeps the app running). When
* absent, `window:getMainBounds` resolves to `null`, so capturing the
* "current size" is impossible. Reflected in the button's disabled
* state + a small explanatory message so the click never silently no-ops.
*
* Default `true` is optimistic: the common case (Cmd+, from main window)
* has the main window present at mount. The probe in the effect below
* corrects to `false` only when the IPC actually returns null.
*/
const [isMainWindowAvailable, setIsMainWindowAvailable] =
useState<boolean>(true)
useComponentEffect(() => {
let cancelled = false
// Attach `.catch` explicitly — `void promise.then(...)` only suppresses
// TypeScript's "promise not handled" hint, it does NOT silence a real
// runtime rejection. If the IPC channel disconnects mid-call we want
// the button to fall back to disabled rather than logging an
// unhandled-promise warning to the renderer console.
window.electron.window
.getMainBounds()
.then((bounds) => {
if (cancelled) return
setIsMainWindowAvailable(bounds !== null)
})
.catch(() => {
if (cancelled) return
setIsMainWindowAvailable(false)
})
return () => {
cancelled = true
}
}, [])
/**
* Capture the live main-window bounds via IPC and persist them.
* `getMainBounds()` resolves to `null` when the main window has been
* closed (Settings can outlive it on macOS) — flip the availability
* flag so the button's disabled state + inline message kick in instead
* of failing silently. Disk write only happens on a real bounds value.
*/
const handleSaveCurrentSize = useCallback(async (): Promise<void> => {
try {
const bounds = await window.electron.window.getMainBounds()
if (bounds === null) {
setIsMainWindowAvailable(false)
return
}
updateSettings({ windowSize: bounds })
} catch {
setIsMainWindowAvailable(false)
}
}, [updateSettings])
const handleSaveCurrentSizeClick = useCallback((): void => {
void handleSaveCurrentSize()
}, [handleSaveCurrentSize])
const handleResetWindowSize = useCallback((): void => {
// `undefined` removes the persisted size; on next launch the main
// window falls back to the default 1200×800 + maximize() behavior.
updateSettings({ windowSize: undefined })
}, [updateSettings])
const persistedWindowSize = settings.windowSize
const hasCustomWindowSize = persistedWindowSize !== undefined
return (
<SectionFrame
title="General"
description="Behavior knobs that apply across the app."
>
<SectionRow
label="Default tab when opening a skill"
description="Which tab the right pane lands on when you select a skill."
>
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={settings.defaultSkillTab}
onValueChange={handleDefaultTabChange}
aria-label="Default tab when opening a skill"
>
{DEFAULT_TAB_OPTIONS.map((option) => {
const Icon = option.icon
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
>
<Icon className="h-3.5 w-3.5" />
<span>{option.label}</span>
</ToggleGroupItem>
)
})}
</ToggleGroup>
</SectionRow>
<SectionRow
label="Preferred terminal"
description='Which app "Open in Terminal" launches for skill folders.'
>
<div className="flex flex-col gap-2">
{/* Native <select> instead of shadcn — fewer deps, native macOS */}
{/* popover menu, free type-to-search and arrow-key nav. Sized to */}
{/* fit longest label ("Terminal (Apple)") at the chosen text size. */}
<select
className="h-9 min-w-56 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={settings.preferredTerminal}
onChange={handlePreferredTerminalChange}
aria-label="Preferred terminal"
>
{TERMINAL_APP_IDS.map((id) => (
<option key={id} value={id}>
{TERMINAL_APP_UI_LABELS[id]}
</option>
))}
</select>
{isCustom && (
<div className="flex flex-col gap-1">
<Input
type="text"
value={customNameDraft}
onChange={handleCustomNameChange}
onBlur={handleCustomNameBlur}
placeholder="e.g. Hyper"
maxLength={CUSTOM_APP_NAME_MAX_LENGTH}
aria-label="Custom terminal app name"
className="min-w-56"
/>
{isDraftBlank ? (
<p className="text-xs text-muted-foreground">
Enter the macOS app name (e.g. <code>Hyper</code>). The app
must be installed in <code>/Applications</code>.
</p>
) : (
<p className="text-xs text-muted-foreground">
Saved when you click outside the field.
</p>
)}
</div>
)}
{/* Footnote disclosure: some terminals (Warp Stable, Ghostty) */}
{/* don't honor `cwd` from the macOS `open -a` flag and may */}
{/* launch at $HOME instead of the requested folder. We can't */}
{/* fix that from our side — surface it so the user isn't surprised. */}
<p className="text-xs text-muted-foreground">
Note: some terminals (Warp, Ghostty) may open at your home directory
instead of the skill folder.
</p>
</div>
</SectionRow>
<SectionRow
label="Startup window size"
description="The size the main window opens at on launch. Saved sizes are clamped to the current display so a window saved on a wide monitor never opens off-screen."
>
<div className="flex flex-col gap-2">
<p
className="text-sm tabular-nums"
aria-label="Current saved startup window size"
>
{hasCustomWindowSize
? `${persistedWindowSize.width} × ${persistedWindowSize.height} px`
: 'Default (maximized on launch)'}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSaveCurrentSizeClick}
disabled={!isMainWindowAvailable}
>
Use current window size
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetWindowSize}
disabled={!hasCustomWindowSize}
>
Reset to default
</Button>
</div>
{!isMainWindowAvailable && (
<p className="text-xs text-muted-foreground">
Main window is closed — open it again, then reopen Settings to
capture its size.
</p>
)}
<p className="text-xs text-muted-foreground">
Takes effect the next time you launch the app.
</p>
</div>
</SectionRow>
</SectionFrame>
)
})