Skip to content

Commit b43d19b

Browse files
committed
feat(plugin-terminals): hash-synced selection, fresh session per load, + tab
- Mirror the active terminal into the URL hash (`#id=<sessionId>`) and react to external hash changes (links, back/forward, manual edits). - Spawn and select a fresh interactive session on every page load. - Move the "new terminal" affordance to a compact "+" pinned at the end of the tab strip. Avoids refocusing the active terminal on background shared-state updates by only fitting/focusing when the selection actually changes.
1 parent a721f7a commit b43d19b

1 file changed

Lines changed: 89 additions & 19 deletions

File tree

plugins/terminals/src/client/index.ts

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const UI_CSS = `
5353
color: var(--dft-muted); font-size: 12px; cursor: pointer; }
5454
.dft-tab:hover { color: var(--dft-fg); }
5555
.dft-tab.active { background: var(--dft-surface-active); color: var(--dft-fg); border-color: var(--dft-border); }
56+
.dft-newtab { min-width: 28px; justify-content: center; font-weight: 600; font-size: 14px; flex: none; }
5657
.dft-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; }
5758
.dft-dot.exited { background: #6e7681; }
5859
.dft-dot.error { background: #f85149; }
@@ -155,15 +156,18 @@ export async function mountTerminals(
155156
const tabs = el('div', 'dft-tabs')
156157
const actions = el('div', 'dft-actions')
157158
const presetSelect = el('select', 'dft-select')
158-
const newShellBtn = el('button', 'dft-btn')
159-
newShellBtn.textContent = '+ Shell'
160-
actions.append(presetSelect, newShellBtn)
159+
actions.append(presetSelect)
161160
header.append(tabs, actions)
162161

162+
// The "new terminal" affordance sits at the end of the tab strip.
163+
const newTabBtn = el('button', 'dft-tab dft-newtab')
164+
newTabBtn.textContent = '+'
165+
newTabBtn.title = 'New terminal'
166+
163167
const toolbar = el('div', 'dft-toolbar')
164168
const body = el('div', 'dft-body')
165169
const empty = el('div', 'dft-empty')
166-
empty.textContent = 'No terminal sessions — start one above.'
170+
empty.textContent = 'No terminal sessions — click + to start one.'
167171
body.append(empty)
168172

169173
root.append(header, toolbar, body)
@@ -174,6 +178,9 @@ export async function mountTerminals(
174178
let presets: TerminalPreset[] = []
175179
let disposed = false
176180
let renamingId: string | null = null
181+
// Session to select once it shows up in the shared-state list (set when
182+
// we spawn one and want to focus it on arrival).
183+
let pendingSelectId: string | null = null
177184

178185
// Follow the system color mode and react to changes at runtime, switching
179186
// both the UI chrome (via CSS classes) and every xterm instance's theme.
@@ -205,17 +212,54 @@ export async function mountTerminals(
205212
return info.customTitle || info.processName || info.title
206213
}
207214

208-
function spawn(req: Parameters<DevframeRpcClient['call']>[1]): void {
209-
rpc.call('devframes-plugin-terminals:spawn', req as any).catch(() => {})
215+
// Selection is mirrored to the URL hash (e.g. `#id=<sessionId>`) so it
216+
// survives links and reacts to back/forward + manual edits.
217+
function readHashId(): string | null {
218+
if (typeof location === 'undefined')
219+
return null
220+
return new URLSearchParams(location.hash.replace(/^#/, '')).get('id')
210221
}
211222

212-
newShellBtn.onclick = () => spawn({ mode: 'interactive' })
223+
function writeHashId(id: string): void {
224+
if (typeof location === 'undefined' || typeof history === 'undefined')
225+
return
226+
const target = `#id=${id}`
227+
if (location.hash !== target)
228+
history.replaceState(history.state, '', target)
229+
}
230+
231+
const onHashChange = (): void => {
232+
const id = readHashId()
233+
if (id && views.has(id) && id !== activeId)
234+
setActive(id, { updateHash: false })
235+
}
236+
if (typeof window !== 'undefined')
237+
window.addEventListener('hashchange', onHashChange)
238+
239+
/** Spawn a session and select it as soon as it appears in the list. */
240+
async function spawnAndSelect(req: Parameters<DevframeRpcClient['call']>[1]): Promise<void> {
241+
try {
242+
const info = await rpc.call('devframes-plugin-terminals:spawn', req as any) as { id?: string }
243+
if (info?.id) {
244+
pendingSelectId = info.id
245+
if (views.has(info.id)) {
246+
pendingSelectId = null
247+
setActive(info.id)
248+
}
249+
}
250+
}
251+
catch {
252+
// Spawn failures surface via server-side diagnostics.
253+
}
254+
}
255+
256+
newTabBtn.onclick = () => void spawnAndSelect({ mode: 'interactive' })
213257

214258
presetSelect.onchange = () => {
215259
const id = presetSelect.value
216260
presetSelect.value = ''
217261
if (id)
218-
spawn({ presetId: id })
262+
void spawnAndSelect({ presetId: id })
219263
}
220264

221265
function renderPresets(): void {
@@ -247,17 +291,28 @@ export async function mountTerminals(
247291
}
248292
}
249293

250-
function setActive(id: string | null): void {
294+
function setActive(id: string | null, opts: { updateHash?: boolean } = {}): void {
295+
const changed = activeId !== id
251296
activeId = id
252297
for (const [vid, view] of views) {
253298
const active = vid === id
254299
view.el.classList.toggle('active', active)
255300
view.tab.classList.toggle('active', active)
256-
if (active) {
257-
requestAnimationFrame(() => {
258-
fitActive()
259-
view.term.focus()
260-
})
301+
}
302+
if (id) {
303+
if (opts.updateHash !== false)
304+
writeHashId(id)
305+
// Only fit + steal focus when the active session actually changed,
306+
// so background shared-state updates (e.g. process-name polling)
307+
// don't refocus the terminal every tick.
308+
if (changed) {
309+
const view = views.get(id)
310+
if (view) {
311+
requestAnimationFrame(() => {
312+
fitActive()
313+
view.term.focus()
314+
})
315+
}
261316
}
262317
}
263318
renderToolbar()
@@ -377,6 +432,8 @@ export async function mountTerminals(
377432
view.tab.title = 'Double-click to rename'
378433
view.tab.append(dot, label)
379434
}
435+
// Keep the "+" affordance pinned to the end of the strip.
436+
tabs.append(newTabBtn)
380437
}
381438

382439
/** Inline-edit a tab name; commits via the rename RPC on Enter/blur. */
@@ -440,8 +497,19 @@ export async function mountTerminals(
440497

441498
if (activeId && !views.has(activeId))
442499
activeId = null
443-
if (!activeId && views.size)
444-
activeId = sessions[sessions.length - 1]?.id ?? views.keys().next().value ?? null
500+
501+
if (pendingSelectId && views.has(pendingSelectId)) {
502+
// A freshly spawned session has arrived — select it.
503+
activeId = pendingSelectId
504+
pendingSelectId = null
505+
}
506+
else if (!activeId && views.size) {
507+
// Otherwise honour the URL hash, else fall back to the newest session.
508+
const hashId = readHashId()
509+
activeId = (hashId && views.has(hashId))
510+
? hashId
511+
: sessions[sessions.length - 1]?.id ?? views.keys().next().value ?? null
512+
}
445513

446514
renderTabs()
447515
setActive(activeId)
@@ -468,9 +536,9 @@ export async function mountTerminals(
468536
syncSessions(full.sessions ?? [])
469537
})
470538

471-
// Auto-create an interactive shell when nothing is running yet.
472-
if (options.autostart !== false && views.size === 0)
473-
spawn({ mode: 'interactive' })
539+
// Each page load spawns a fresh interactive session and selects it.
540+
if (options.autostart !== false)
541+
void spawnAndSelect({ mode: 'interactive' })
474542

475543
const resizeObserver = typeof ResizeObserver !== 'undefined'
476544
? new ResizeObserver(() => fitActive())
@@ -484,6 +552,8 @@ export async function mountTerminals(
484552
offSessions?.()
485553
offPresets?.()
486554
colorScheme?.removeEventListener('change', onColorScheme)
555+
if (typeof window !== 'undefined')
556+
window.removeEventListener('hashchange', onHashChange)
487557
resizeObserver?.disconnect()
488558
for (const view of views.values())
489559
disposeView(view)

0 commit comments

Comments
 (0)