/** Browser download state shared by the Session Header button and `/export`. */ import { createSnapshotStore, type SessionId, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' /** Download phases presented by the shared modal. */ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' /** One Session's current download-dialog state. */ export interface SessionLogDownloadEntry { readonly open: boolean readonly status: SessionLogDownloadStatus readonly error: string | null } /** Download states keyed by the Session whose Header owns the dialog. */ export interface SessionLogDownloadState { bySession: Record } type Fetch = (input: string | URL, init?: RequestInit) => Promise type Save = (url: string, filename: string) => void const INITIAL: SessionLogDownloadState = { bySession: {} } /** * Collapse an untrusted Session id into the filename convention owned by the host endpoint. * @param sessionId - Session whose archive is downloaded. * @returns one safe browser download filename. */ export function sessionLogZipFilename(sessionId: SessionId): string { return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip` } /** * Hand a Host download URL to the browser download manager. * @param url - same-origin Host download URL. * @param filename - browser download filename. */ export function downloadUrl(url: string, filename: string): void { const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() } /** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ function hostBase(): string { const origin = (globalThis as { location?: { origin?: string } }).location?.origin return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal' } function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } /** Owns one in-flight browser download per Session and publishes modal state. */ export class SessionLogDownloadController { /** uSES-safe state source shared by every Session-scoped modal contribution. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) private readonly active = new Map }>() private disposed = false /** * @param fetcher - HTTP carrier used to read the host-streamed ZIP. * @param save - browser save operation. */ constructor( private readonly fetcher: Fetch = (input, init) => fetch(input, init), private readonly save: Save = downloadUrl, ) {} /** * Download one Session tree; concurrent gestures for the same Session share one operation. * @param sessionId - root Session whose ZIP includes descendants and attachments. * @returns after the browser save starts, an error state is published, or a late post-disposal request is ignored. */ download(sessionId: SessionId): Promise { const existing = this.active.get(sessionId) if (existing !== undefined) return existing.done if (this.disposed) return Promise.resolve() const abort = new AbortController() const done = this.run(sessionId, abort.signal).finally(() => { this.active.delete(sessionId) }) this.active.set(sessionId, { abort, done }) return done } /** * Close one Session's dialog without cancelling an in-flight browser download. * @param sessionId - Session whose modal closes. */ dismiss(sessionId: SessionId): void { const current = this.store.getSnapshot().bySession[String(sessionId)] if (current === undefined || !current.open) return this.publish(sessionId, { ...current, open: false }) } /** * Abort active fetches and reach quiescence. * @returns after every active operation settles. */ async dispose(): Promise { this.disposed = true const active = [...this.active.values()] for (const operation of active) operation.abort.abort() await Promise.allSettled(active.map(operation => operation.done)) } private async run(sessionId: SessionId, signal: AbortSignal): Promise { this.publish(sessionId, { open: true, status: 'downloading', error: null }) try { const url = new URL('/api/session.export', hostBase()) url.searchParams.set('sessionId', sessionId) url.searchParams.set('includeDescendants', 'true') const response = await this.fetcher(url, { method: 'HEAD', signal }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } this.save(url.toString(), sessionLogZipFilename(sessionId)) const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true this.publish(sessionId, { open, status: 'success', error: null }) } catch (error: unknown) { if (signal.aborted) return const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true this.publish(sessionId, { open, status: 'error', error: messageOf(error) }) } } private publish(sessionId: SessionId, entry: SessionLogDownloadEntry): void { this.store.update((state) => { state.bySession = { ...state.bySession, [String(sessionId)]: entry } }) } }