diff --git a/electron/services/ConnectionManager.ts b/electron/services/ConnectionManager.ts index 87750f6..c34e2f7 100644 --- a/electron/services/ConnectionManager.ts +++ b/electron/services/ConnectionManager.ts @@ -66,6 +66,8 @@ class ConnectionManagerImpl { private cwds = new Map() /** Recent output per pane, for clients that attach after a pane is already running. */ private scrollback = new Map() + /** Geometry reported for a tab whose backend is still being spawned (see resize). */ + private pendingGeometry = new Map() private taps = new Set<(e: TapEvent) => void>() /** Subscribe to terminal traffic from outside the renderer. Returns an unsubscribe fn. */ @@ -103,14 +105,22 @@ class ConnectionManagerImpl { register(tabId: string, backend: TerminalBackend, sessionName: string, host: string | null, sessionId: number | null, owner: number | null = null): void { const logId = logRepo.start(sessionId, sessionName, host) - // Placeholder geometry — the first client to fit its terminal overwrites it via resize(). - this.entries.set(tabId, { backend, logId, sessionName, host, sessionId, owner, cols: 80, rows: 24 }) + // Geometry the client already reported while we were still connecting, else a placeholder. + const geo = this.pendingGeometry.get(tabId) + this.pendingGeometry.delete(tabId) + this.entries.set(tabId, { backend, logId, sessionName, host, sessionId, owner, cols: geo?.cols ?? 80, rows: geo?.rows ?? 24 }) + if (geo) this.resize(tabId, geo.cols, geo.rows) } /** Resize a backend and remember the geometry so other clients can match it. */ resize(tabId: string, cols: number, rows: number): void { const e = this.entries.get(tabId) - if (!e) return + // Spawning is async, so the client's real geometry usually lands before the backend does. + // Dropping it leaves the shell wrapping at the cols we guessed, which corrupts redraws. + if (!e) { + this.pendingGeometry.set(tabId, { cols, rows }) + return + } e.cols = cols e.rows = rows e.backend.resize(cols, rows) @@ -206,6 +216,8 @@ class ConnectionManagerImpl { this.recorders.delete(tabId) this.cwds.delete(tabId) this.scrollback.delete(tabId) + // A connection that failed to spawn never consumed its pending geometry. + this.pendingGeometry.delete(tabId) } private finishLog(tabId: string, reason: string): void { diff --git a/src/components/settings/UpdateSettings.tsx b/src/components/settings/UpdateSettings.tsx index 29d4564..9c678aa 100644 --- a/src/components/settings/UpdateSettings.tsx +++ b/src/components/settings/UpdateSettings.tsx @@ -4,22 +4,44 @@ import { useUiStore } from '@/store/useUiStore' import { formatSpeed } from '@/utils/formatBytes' type UpdateState = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' -function parseNotes(raw: unknown): string[] { + +/** One line of a release body: a section label, or a bullet under the label above it. */ +export interface ReleaseNote { + text: string + heading: boolean +} + +/** + * Release bodies arrive as GitHub-rendered HTML, where a `**Section**` line is a bare

and + * only bullets are

  • . Walking both in document order keeps each label with its bullets. + */ +export function parseNotes(raw: unknown): ReleaseNote[] { const html = Array.isArray(raw) ? raw.map((n: any) => n?.note ?? '').join('\n') : typeof raw === 'string' ? raw : '' if (!html.trim()) return [] const body = new DOMParser().parseFromString(html, 'text/html').body - const items = [...body.querySelectorAll('li')].map((li) => li.textContent?.trim() ?? '') - const lines = items.length ? items : (body.textContent ?? '').split('\n') - return lines.map((l) => l.replace(/^[-*•]\s*/, '').trim()).filter(Boolean) + const notes = [...body.querySelectorAll('li, p, h1, h2, h3, h4')] + // A

    inside an

  • is that bullet's own text (loose lists), else it lands twice. + .filter((el) => el.tagName === 'LI' || !el.closest('li')) + .map((el) => ({ + text: (el.textContent ?? '').replace(/^[-*•]\s*/, '').trim(), + heading: el.tagName !== 'LI' + })) + .filter((n) => n.text) + if (notes.some((n) => !n.heading)) return notes + // Nothing list-shaped in there: fall back to plain lines, as before. + return (body.textContent ?? '') + .split('\n') + .map((l) => ({ text: l.replace(/^[-*•]\s*/, '').trim(), heading: false })) + .filter((n) => n.text) } export function UpdateSettings() { const [version, setVersion] = useState('') const [state, setState] = useState('idle') const [newVersion, setNewVersion] = useState('') - const [notes, setNotes] = useState([]) + const [notes, setNotes] = useState([]) const [progress, setProgress] = useState({ percent: 0, speed: 0 }) const notify = useUiStore((s) => s.notify) @@ -104,13 +126,19 @@ export function UpdateSettings() { {notes.length > 0 && state !== 'idle' && (
    {`What's new in v${newVersion}`}
    -
      - {notes.map((n, i) => ( -
    • - - {n} -
    • - ))} +
        + {notes.map((n, i) => + n.heading ? ( +
      • + {n.text} +
      • + ) : ( +
      • + + {n.text} +
      • + ) + )}
    )} diff --git a/src/hooks/useTerminal.ts b/src/hooks/useTerminal.ts index 0b03361..67fd6dc 100644 --- a/src/hooks/useTerminal.ts +++ b/src/hooks/useTerminal.ts @@ -344,7 +344,7 @@ export function useTerminal(pane: Pane): TerminalController { return () => { cancelled = true } - }, [fontFamily, fontSize, lineHeight, letterSpacing]) + }, [fontFamily, fontSize, lineHeight, letterSpacing, pane.id]) return { containerRef, terminal, search, fit, focus, paste, clear } }