227 lines
9.0 KiB
TypeScript
227 lines
9.0 KiB
TypeScript
/**
|
|
* Client half of dsh-better-sidebar: resolves the user's "Side card"
|
|
* preferences through the plugin's own fenced settings route, mounts the
|
|
* right sidebar portal (inside an error boundary so a rendering failure
|
|
* shows an error strip instead of a blank panel), registers the turn-tail
|
|
* interception, and contributes the Side card settings section to the DSH
|
|
* Settings shell. Requires the runtime's slots and sessions services; the
|
|
* bundle itself is a module-table consumer only (react + ui-primitives +
|
|
* xterm, all provided or inlined).
|
|
*/
|
|
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import type { Context } from '../context-types.ts'
|
|
import { createSidebarStore } from './state.ts'
|
|
import { createBetterSidebarService } from './service.ts'
|
|
import { resetChunks } from './chunk-loader.ts'
|
|
import { registerBuiltins } from './builtins/index.ts'
|
|
import { Sidebar } from './Sidebar.tsx'
|
|
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
|
import { registerLinkInterception } from './link-intercept.ts'
|
|
import { registerImeGuard } from './ime-guard.ts'
|
|
import { loadPrefs } from './prefs.ts'
|
|
import { SideCardSection } from './SideCardSection.tsx'
|
|
import { api } from './api.ts'
|
|
import { t } from './locales.ts'
|
|
import css from './sidebar.module.css'
|
|
import './layout.css'
|
|
|
|
/** Services required before mounting (provided by the client runtime). */
|
|
export const inject = ['slots', 'sessions', 'connection', 'workspaces']
|
|
|
|
/**
|
|
* Error boundary over the sidebar tree: a render error must never blank the
|
|
* whole panel silently — it shows a dismissible error strip and logs the
|
|
* stack for diagnosis.
|
|
*/
|
|
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
|
state = { error: null as string | null }
|
|
|
|
static getDerivedStateFromError(error: unknown): { error: string } {
|
|
return { error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
|
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
|
}
|
|
|
|
render(): ReactNode {
|
|
if (this.state.error !== null) {
|
|
return (
|
|
<div className={css.boundaryError}>
|
|
<span>dsh-better-sidebar: {this.state.error}</span>
|
|
<button
|
|
type="button"
|
|
className={css.terminalRetry}
|
|
onClick={() => { this.setState({ error: null }) }}
|
|
>
|
|
{t('terminalRetry')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
return this.props.children
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Client plugin body.
|
|
* @param ctx - the client cordis context (slots, sessions).
|
|
*/
|
|
export function apply(ctx: Context): void {
|
|
// One store instance per activation: production code creates it only here,
|
|
// then hands it to the mounted panel and closes over it in the slot
|
|
// registrations (the official createXXXStore() factory rule — no
|
|
// module-level singleton).
|
|
const sidebarStore = createSidebarStore()
|
|
// The sidebar registry service: external plugins register tab types and
|
|
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
|
|
// Published before the panel mounts so consumers injecting 'betterSidebar'
|
|
// are ready by the time the sidebar renders.
|
|
const service = createBetterSidebarService(sidebarStore)
|
|
ctx.provide('betterSidebar', service)
|
|
// Register the plugin's own built-in tabs and viewers through the same
|
|
// service (eating our own dogfood). The disposer unregisters them on
|
|
// fiber disposal (HMR-safe).
|
|
ctx.effect(
|
|
() => registerBuiltins(ctx, service),
|
|
'dsh-better-sidebar: register built-in tabs and viewers',
|
|
)
|
|
// A failure anywhere in the client lifecycle must never take the app down
|
|
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
|
// to the page so a blank panel is never the only symptom.
|
|
const fail = (phase: string, error: unknown): void => {
|
|
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
|
|
try {
|
|
const bar = document.createElement('div')
|
|
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
|
|
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
|
|
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
|
|
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
|
|
document.body.appendChild(bar)
|
|
} catch {
|
|
// Nothing left to report with.
|
|
}
|
|
}
|
|
try {
|
|
// Fresh chunk state for this activation: invalidate any chunk factories
|
|
// registered by a previous fiber (HMR) and drop the in-memory load cache
|
|
// so the next lazy open re-fetches the current chunk scripts.
|
|
resetChunks()
|
|
ctx.effect(() => {
|
|
let disposed = false
|
|
let root: Root | undefined
|
|
let host: HTMLDivElement | undefined
|
|
void (async () => {
|
|
// Resolve the user's side card prefs BEFORE the first session seeds,
|
|
// so a brand-new conversation opens (or stays closed) at the chosen
|
|
// width from first paint. A settings route failure falls back to the
|
|
// schema defaults; the sidebar still mounts (a stalled wire gives up
|
|
// after the timeout and mounts on the defaults).
|
|
const prefs = await Promise.race([
|
|
loadPrefs(api),
|
|
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
|
])
|
|
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
|
if (disposed) return
|
|
try {
|
|
host = document.createElement('div')
|
|
host.setAttribute('data-dsh-better-sidebar', '')
|
|
document.body.appendChild(host)
|
|
root = createRoot(host)
|
|
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
|
} catch (error) {
|
|
fail('mount', error)
|
|
}
|
|
})()
|
|
return () => {
|
|
disposed = true
|
|
root?.unmount()
|
|
host?.remove()
|
|
}
|
|
}, 'dsh-better-sidebar: sidebar mount')
|
|
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
return registerTurnTailInterception(ctx, sidebarStore)
|
|
} catch (error) {
|
|
fail('interception', error)
|
|
return undefined
|
|
}
|
|
},
|
|
'dsh-better-sidebar: turn-tail interception',
|
|
)
|
|
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
return registerOpenPathInterception(ctx, sidebarStore)
|
|
} catch (error) {
|
|
fail('interception', error)
|
|
return undefined
|
|
}
|
|
},
|
|
'dsh-better-sidebar: open-path interception',
|
|
)
|
|
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
// External http(s) links in the chat/GUI open the sidebar browser
|
|
// instead of a new window (gated on the browserInterceptLinks pref
|
|
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
|
|
return registerLinkInterception({
|
|
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
|
|
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
|
|
openInSidebar: (url) => {
|
|
let title: string | undefined
|
|
try { title = new URL(url).hostname } catch { /* keep the default title */ }
|
|
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
|
|
},
|
|
selfOrigin: window.location.origin,
|
|
})
|
|
} catch (error) {
|
|
fail('interception', error)
|
|
return undefined
|
|
}
|
|
},
|
|
'dsh-better-sidebar: link interception',
|
|
)
|
|
|
|
// The IME guard: composition keys (candidate arrows, confirm, cancel)
|
|
// belong to the input method, never to page JS. Inlined third-party UI
|
|
// (Univer's office controls) has shipped unguarded keydown handlers that
|
|
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
|
|
// document-capture guard neutralizes the whole class before React or any
|
|
// native listener sees the event. Registered as early as possible so no
|
|
// other capture-phase listener can win the ordering race.
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
return registerImeGuard()
|
|
} catch (error) {
|
|
fail('ime guard', error)
|
|
return undefined
|
|
}
|
|
},
|
|
'dsh-better-sidebar: IME composition guard',
|
|
)
|
|
|
|
// The "Side card" settings section: appears in the DSH Settings shell
|
|
// once the shell's declaration is on the ledger (slots.inject waits for
|
|
// it); the section reads/writes the prefs through the plugin's own
|
|
// fenced settings route, keeps the shared store in sync, and renders the
|
|
// declarative enable/disable inventory from the tab/viewer registry.
|
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
name: 'settings.section',
|
|
id: 'better-sidebar',
|
|
order: 100,
|
|
label: () => t('settingsNav'),
|
|
inject: () => ({ store: sidebarStore, service }),
|
|
}, SideCardSection))
|
|
} catch (error) {
|
|
fail('load', error)
|
|
}
|
|
}
|