424 lines
20 KiB
TypeScript
424 lines
20 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 { createElement } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import type { Context } from '../context-types.ts'
|
|
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
|
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
|
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
|
import { registerBuiltins } from './builtins/index.ts'
|
|
import { Sidebar } from './Sidebar.tsx'
|
|
import { RenderBoundary } from './RenderBoundary.tsx'
|
|
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
|
import { registerLinkInterception } from './link-intercept.ts'
|
|
import { registerImeGuard } from './ime-guard.ts'
|
|
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
import { loadExternalDisable, loadPrefs } from './prefs.ts'
|
|
import { SideCardSection } from './SideCardSection.tsx'
|
|
import { api } from './api.ts'
|
|
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en,
|
|
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
|
|
zhHK, zhTW, zhMO,
|
|
} from './locales.ts'
|
|
import css from './sidebar.module.css'
|
|
import './layout.css'
|
|
|
|
/** Services required before mounting (provided by the client runtime; the
|
|
* locale service backs the sidebar's copy — see locales.ts). `modules`
|
|
* (rc.8+) is the client module system the chunk loader resolves its
|
|
* externals through — Cordis guards service access without inject. */
|
|
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
|
|
|
|
/**
|
|
* Error boundary over the sidebar tree (root scope): a render error in the
|
|
* sidebar SHELL itself must never blank the page silently — the shared
|
|
* RenderBoundary shows a dismissible error strip and logs the stack. The
|
|
* per-tab scope (Sidebar.tsx) catches viewer/editor crashes first; this root
|
|
* boundary stays as the last resort for Workbench/shell errors.
|
|
*/
|
|
/**
|
|
* Client plugin body.
|
|
* @param ctx - the client cordis context (slots, sessions).
|
|
*/
|
|
export function apply(ctx: Context): void {
|
|
// The sidebar follows the DSH i18n system: attach the locale service so
|
|
// the module-level t()/isZh() resolve the Host-backed language preference
|
|
// (and switch live — the Sidebar root subscribes to it), and register the
|
|
// plugin's dictionaries into the shared locale registry. The disposers
|
|
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
|
|
attachLocale(ctx.locale)
|
|
ctx.effect(() => {
|
|
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
|
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
|
return () => { offZh(); offEn() }
|
|
}, 'dsh-better-sidebar: dictionaries')
|
|
|
|
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
|
|
// When that plugin is installed, it publishes `ctx.betterLocale` (the
|
|
// override store) and patches LocaleRuntime.prototype.lookup to consult
|
|
// it. We mirror the same override awareness into the sidebar's own `t()`:
|
|
// attachBetterLocale() makes t() consult the store's getOverride first,
|
|
// so the sidebar's chrome (which bypasses ctx.locale and calls t()
|
|
// directly) also switches to the override language. We also register
|
|
// the ja dict with the better-locale store so external callers of
|
|
// ctx.locale.lookup('betterSidebar', key) get the override text too.
|
|
//
|
|
// Activation-order-safe: ctx.get('betterLocale') is a non-reactive read
|
|
// (cordis only re-evaluates declared `inject` deps). If better-locale
|
|
// activates after better-sidebar, the initial read returns undefined.
|
|
// We subscribe to the locale revision — better-locale bumps it on
|
|
// activation (when a persisted override exists) and on every override
|
|
// switch — and re-check ctx.get on each bump, attaching + registering
|
|
// the ja dict once the store becomes available.
|
|
ctx.effect(() => {
|
|
let dispose: (() => void) | undefined
|
|
const sync = (): void => {
|
|
dispose?.()
|
|
dispose = undefined
|
|
const store = ctx.get('betterLocale') as
|
|
| {
|
|
readonly active: string | undefined
|
|
getOverride(dshActive: string, ns: string, key: string): string | undefined
|
|
isOverrideActive(dshActive: string): boolean
|
|
register(ns: string, dicts: Record<string, Record<string, string>>): () => void
|
|
subscribe(listener: () => void): () => void
|
|
}
|
|
| undefined
|
|
attachBetterLocale(store)
|
|
if (store !== undefined) {
|
|
dispose = store.register(LOCALE_NS, {
|
|
ja, de, fr, pt, ko, ar, hi, id, tr, vi, th, ru, it, nl, sv, pl,
|
|
'zh-HK': zhHK, 'zh-TW': zhTW, 'zh-MO': zhMO,
|
|
})
|
|
}
|
|
}
|
|
// Initial check (picks up the store if better-locale activated first).
|
|
sync()
|
|
// Re-check on every locale revision bump (better-locale bumps when it
|
|
// activates with a persisted override, and when the user switches).
|
|
const unsubscribe = ctx.locale.subscribe(sync)
|
|
return () => {
|
|
unsubscribe()
|
|
dispose?.()
|
|
attachBetterLocale(undefined)
|
|
}
|
|
}, 'dsh-better-sidebar: better-locale lazy integration')
|
|
// 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)
|
|
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
|
|
// instead of "Terminal 1". Start with a safe fallback and replace it as
|
|
// soon as the host shell info resolves. Tabs created before the response
|
|
// arrives keep the fallback title, so also retitle any already-open UI
|
|
// terminal tabs that still carry it.
|
|
const fallbackTitle = t('terminal')
|
|
let terminalTitle = fallbackTitle
|
|
void api.shellGet().then(({ name }) => {
|
|
terminalTitle = name
|
|
const snapshot = service.getSnapshot()
|
|
if (snapshot.state === undefined) return
|
|
const tabs = allLeaves(snapshot.state.splits)
|
|
.concat(allLeaves(snapshot.state.bottomSplits))
|
|
.flatMap(leaf => leaf.tabs)
|
|
for (const tab of tabs) {
|
|
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
|
|
service.updateTab(tab.id, { title: name })
|
|
}
|
|
}
|
|
}).catch(() => { /* keep fallback */ })
|
|
// 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, { terminalTitle: () => terminalTitle }),
|
|
'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 {
|
|
// rc.8+ exposes the client module system as the `ctx.modules` service
|
|
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
|
|
// it to resolve its externals, so inject it before anything can load a
|
|
// lazy chunk. The loader falls back to the rc.7 global when absent.
|
|
setChunkModuleSystem(ctx.modules)
|
|
// Fresh chunk state for this activation: drop per-test fixtures and
|
|
// revalidate loaded chunk scripts against the bundle route's ETags —
|
|
// unchanged chunks keep their resolved exports (no re-inject /
|
|
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
|
|
void revalidateChunksOnReactivate()
|
|
ctx.effect(() => {
|
|
let disposed = false
|
|
let root: Root | undefined
|
|
let host: HTMLDivElement | undefined
|
|
let mounted = false
|
|
let bodyObserver: MutationObserver | undefined
|
|
let hostCheckFrame: number | null = null
|
|
const unmount = (): void => {
|
|
if (!mounted) return
|
|
mounted = false
|
|
bodyObserver?.disconnect()
|
|
bodyObserver = undefined
|
|
if (hostCheckFrame !== null) {
|
|
cancelAnimationFrame(hostCheckFrame)
|
|
hostCheckFrame = null
|
|
}
|
|
root?.unmount()
|
|
root = undefined
|
|
host?.remove()
|
|
host = undefined
|
|
}
|
|
/** Re-attach the host if the page (a desktop shell wrapper, SPA
|
|
* navigation, …) ever removes it from <body>. Cheap: childList only,
|
|
* no subtree, no attribute filtering. */
|
|
const guardAnchor = (): void => {
|
|
if (bodyObserver !== undefined) return
|
|
bodyObserver = new MutationObserver(() => {
|
|
if (host !== undefined && !document.body.contains(host)) {
|
|
document.body.appendChild(host)
|
|
}
|
|
})
|
|
bodyObserver.observe(document.body, { childList: true })
|
|
}
|
|
/** One-shot geometry self-check: if the host page transforms
|
|
* <html>/<body> itself (exotic shells), a fixed panel host would
|
|
* track the transformed box instead of the viewport. Flip the
|
|
* degraded mode and pin the host to the viewport every frame until
|
|
* the ancestor transform is actually gone. The normal path (no
|
|
* page-level transform) never runs the sync loop. */
|
|
const scheduleHostCheck = (): void => {
|
|
hostCheckFrame ??= requestAnimationFrame(() => {
|
|
hostCheckFrame = null
|
|
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
|
|
if (layer === null || layer === undefined) return
|
|
const rect = layer.getBoundingClientRect()
|
|
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|
|
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
|
|
if (!mismatched) {
|
|
layer.removeAttribute('data-dsh-panel-host-degraded')
|
|
layer.style.transform = ''
|
|
return
|
|
}
|
|
layer.setAttribute('data-dsh-panel-host-degraded', '')
|
|
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
|
|
// Track our own compensating translation so the loop judges the
|
|
// UNCORRECTED geometry: clearing degraded mode must wait for the
|
|
// ancestor transform to actually disappear — the frame right after
|
|
// our correction applies would otherwise look "fixed" and the
|
|
// offset would return immediately (CR #232 P1).
|
|
let applied = { x: 0, y: 0 }
|
|
const sync = (): void => {
|
|
const r = layer.getBoundingClientRect()
|
|
const rawLeft = r.left - applied.x
|
|
const rawTop = r.top - applied.y
|
|
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
|
|
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
|
|
layer.removeAttribute('data-dsh-panel-host-degraded')
|
|
layer.style.transform = ''
|
|
return
|
|
}
|
|
const next = { x: -rawLeft, y: -rawTop }
|
|
if (next.x !== applied.x || next.y !== applied.y) {
|
|
applied = next
|
|
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
|
|
}
|
|
hostCheckFrame = requestAnimationFrame(sync)
|
|
}
|
|
hostCheckFrame = requestAnimationFrame(sync)
|
|
})
|
|
}
|
|
const mount = (): void => {
|
|
if (mounted || disposed) return
|
|
try {
|
|
host = document.createElement('div')
|
|
host.setAttribute('data-dsh-better-sidebar', '')
|
|
document.body.appendChild(host)
|
|
root = createRoot(host)
|
|
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
|
|
mounted = true
|
|
guardAnchor()
|
|
scheduleHostCheck()
|
|
} catch (error) {
|
|
fail('mount', error)
|
|
}
|
|
}
|
|
const sync = async (): Promise<void> => {
|
|
if (disposed) return
|
|
// 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
|
|
// Mutual exclusion with the dsh-web-ui family right panel: while the
|
|
// aionui-panel provider is selected, the sidebar must not mount at
|
|
// all. Re-evaluated on every settings-document update (live switch).
|
|
const suspended = await loadExternalDisable(api)
|
|
if (disposed) return
|
|
sidebarStore.setSuspended(suspended)
|
|
if (suspended) unmount()
|
|
else mount()
|
|
}
|
|
void sync()
|
|
// Live re-evaluation: the runtime broadcasts settings-document updates
|
|
// (the aionui card saves through the same document). Best effort —
|
|
// deployments without the 'remote' service fall back to boot-time
|
|
// evaluation only.
|
|
const remote = ctx.get('remote') as { $on?: (event: string, listener: () => void) => () => void } | undefined
|
|
const offRemote = remote?.$on?.('settings/document-updated', () => { void sync() })
|
|
return () => {
|
|
disposed = true
|
|
offRemote?.()
|
|
unmount()
|
|
}
|
|
}, 'dsh-better-sidebar: sidebar mount')
|
|
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
return registerTurnTailInterception(ctx, sidebarStore)
|
|
} catch (error) {
|
|
fail('interception', error)
|
|
return () => {}
|
|
}
|
|
},
|
|
'dsh-better-sidebar: turn-tail interception',
|
|
)
|
|
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
return registerOpenPathInterception(ctx, sidebarStore)
|
|
} catch (error) {
|
|
fail('interception', error)
|
|
return () => {}
|
|
}
|
|
},
|
|
'dsh-better-sidebar: open-path interception',
|
|
)
|
|
|
|
ctx.effect(
|
|
() => {
|
|
try {
|
|
// External http(s) links in the chat/GUI open the sidebar instead
|
|
// of a new window. Gated on the browserInterceptLinks MASTER pref,
|
|
// the URL's protocol flag (browserInterceptHttp / Https — https
|
|
// defaults OFF: most https sites refuse iframe embedding), and the
|
|
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
|
|
// target is the first registered tab whose `urlTarget` claims the
|
|
// URL (enabled tabs only), else the built-in browser tab.
|
|
const urlTargetOf = (url: URL): string | undefined => {
|
|
const prefs = sidebarStore.getPrefs()
|
|
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
|
|
return matchUrlTarget(enabled, url)?.id
|
|
}
|
|
return registerLinkInterception({
|
|
takeoverEnabled: (url) => {
|
|
if (sidebarStore.getSuspended()) return false
|
|
const prefs = sidebarStore.getPrefs()
|
|
if (prefs.browserInterceptLinks === false) return false
|
|
const protocolOn = url.protocol === 'https:'
|
|
? prefs.browserInterceptHttps !== false
|
|
: prefs.browserInterceptHttp !== false
|
|
if (!protocolOn) return false
|
|
// A plugin claim is the target (already enabled-filtered);
|
|
// otherwise the built-in browser must be enabled.
|
|
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
|
|
},
|
|
openInSidebar: (url) => {
|
|
let title: string | undefined
|
|
try { title = new URL(url).hostname } catch { /* keep the default title */ }
|
|
const type = urlTargetOf(new URL(url)) ?? 'browser'
|
|
ctx.get('betterSidebar')?.openTab({ type, url, title })
|
|
},
|
|
selfOrigin: window.location.origin,
|
|
})
|
|
} catch (error) {
|
|
fail('interception', error)
|
|
return () => {}
|
|
}
|
|
},
|
|
'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
|
|
// (formerly Univer's office controls, #562 regression) has shipped
|
|
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
|
|
// Chinese input; 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 () => {}
|
|
}
|
|
},
|
|
'dsh-better-sidebar: IME composition guard',
|
|
)
|
|
|
|
// DSH 0.1.x does not yet carry an icon through the settings.section
|
|
// registration contract: its shell renders a generic gear for every
|
|
// external section. Mark only this plugin's localized nav row so
|
|
// layout.css can paint the requested Side card SVG; the disposer clears
|
|
// the marker for HMR / plugin disable.
|
|
ctx.effect(
|
|
() => registerSettingsNavIcon(() => t('settingsNav')),
|
|
'dsh-better-sidebar: settings navigation icon',
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|