fix: 合并 main 后的测试回归修复

- index.ts: 移除 #130 遗留的 ctx.on('session/disposed')(fake ctx 无 on,
  且 #130 已按取 HEAD 决策,closeSession 调用方一并移除)
- browser.probe: 405→GET 重试后跳过 embed-signals GET,避免多余第 3 次请求(#69)
- EditorHost: 移植 #228 的 dirty 确认守卫到 HEAD 的 reloadSeq 结构(refreshFile)
- tests: fake ctx 补 get()(#357 ctx.get 回归,free-window/editor-refresh/markdown)
- tests: prefs 期望对象补 browserAllowedLoopback(smoke/plugin-shape/state)
- tests: builtins 浏览器设置行补 browserAllowedLoopback(#69)
- tests: install-powershell BOM 门限仅对实际文档化远程调用的文件生效
- locales: 19 语言补齐 showInFolder / refreshUnsavedConfirm(#94 #215 #228)
This commit is contained in:
HuanLinOTO
2026-08-24 17:54:33 +08:00
parent f4e71c1257
commit 6001f0167c
30 changed files with 77 additions and 19 deletions
+13 -1
View File
@@ -113,6 +113,18 @@ export function EditorHost(props: {
// with the same path/scope — the only reload entry besides open/close.
const [reloadSeq, setReloadSeq] = useState(0)
// Manual refresh (issue #167 + PR #228): a dirty draft is dropped by the
// reload (the editor instance remounts), so confirm before discarding it.
const refreshFile = (): void => {
if (toolbar?.dirty === true) {
const confirmed = typeof window.confirm === 'function'
? window.confirm(t('refreshUnsavedConfirm'))
: false
if (!confirmed) return
}
setReloadSeq(sequence => sequence + 1)
}
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
// reload. The snapshot is the bare boolean so unrelated store churn never
// re-renders the editor.
@@ -426,7 +438,7 @@ export function EditorHost(props: {
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setReloadSeq(sequence => sequence + 1) }}
onClick={refreshFile}
>
<IconRefreshOutline14 size={14} />
</button>
+2
View File
@@ -88,6 +88,8 @@ export const ar: Record<string, string> = {
mermaidZoomReset: 'إعادة تعيين',
mermaidZoomHint: 'العجلة للتقريب · السحب للتحريك · Esc للإغلاق',
refresh: 'تحديث',
showInFolder: 'إظهار في المجلد',
refreshUnsavedConfirm: 'تغيّر الملف على القرص. سيؤدي التحديث إلى فقدان التعديلات غير المحفوظة. متابعة؟',
save: 'حفظ',
saved: 'تم الحفظ',
unsaved: 'غير محفوظ',
+2
View File
@@ -73,6 +73,8 @@ export const de: Record<string, string> = {
mermaidZoomReset: 'Zurücksetzen',
mermaidZoomHint: 'Scrollen zum Zoomen · Ziehen zum Verschieben · Esc zum Schließen',
refresh: 'Aktualisieren',
showInFolder: 'Im Ordner anzeigen',
refreshUnsavedConfirm: 'Die Datei wurde geändert. Beim Aktualisieren gehen ungespeicherte Änderungen verloren. Fortfahren?',
save: 'Speichern',
saved: 'Gespeichert',
unsaved: 'Nicht gespeichert',
+2
View File
@@ -80,6 +80,8 @@ export const fr: Record<string, string> = {
mermaidZoomReset: 'Réinitialiser',
mermaidZoomHint: 'Molette pour zoomer · glisser pour déplacer · Échap pour fermer',
refresh: 'Actualiser',
showInFolder: 'Afficher dans le dossier',
refreshUnsavedConfirm: 'Le fichier a changé sur le disque. Actualiser supprimera les modifications non enregistrées. Continuer ?',
save: 'Enregistrer',
saved: 'Enregistré',
unsaved: 'Non enregistré',
+2
View File
@@ -87,6 +87,8 @@ export const hi: Record<string, string> = {
mermaidZoomReset: 'रीसेट',
mermaidZoomHint: 'स्क्रॉल से ज़ूम · खींचें पैन करने · Esc से बंद',
refresh: 'ताज़ा करें',
showInFolder: 'फ़ोल्डर में दिखाएँ',
refreshUnsavedConfirm: 'डिस्क पर फ़ाइल बदल गई है। रीफ़्रेश करने पर असहेज किए गए संपादन खो जाएँगे। जारी रखें?',
save: 'सहेजें',
saved: 'सहेजा गया',
unsaved: 'असहेजित',
+2
View File
@@ -85,6 +85,8 @@ export const id: Record<string, string> = {
mermaidZoomReset: 'Reset',
mermaidZoomHint: 'Gulir untuk zoom · seret untuk geser · Esc untuk tutup',
refresh: 'Segarkan',
showInFolder: 'Tampilkan di folder',
refreshUnsavedConfirm: 'File berubah di disk. Menyegarkan akan membuang edit yang belum disimpan. Lanjutkan?',
save: 'Simpan',
saved: 'Tersimpan',
unsaved: 'Belum disimpan',
+2
View File
@@ -78,6 +78,8 @@ export const it: Record<string, string> = {
mermaidZoomReset: 'Ripristina',
mermaidZoomHint: 'Scorra per zoom · trascini per panoramica · Esc per chiudere',
refresh: 'Aggiorna',
showInFolder: 'Mostra nella cartella',
refreshUnsavedConfirm: 'Il file è cambiato su disco. Aggiornare scarterà le modifiche non salvate. Continuare?',
save: 'Salva',
saved: 'Salvato',
unsaved: 'Non salvato',
+2
View File
@@ -87,6 +87,8 @@ export const ja: Record<string, string> = {
mermaidZoomReset: 'リセット',
mermaidZoomHint: 'スクロールで拡大 ・ ドラッグで移動 ・ Esc で閉じる',
refresh: '更新',
showInFolder: 'フォルダーで表示',
refreshUnsavedConfirm: 'ディスク上のファイルが変更されました。更新すると未保存の編集が失われます。続行しますか?',
save: '保存',
saved: '保存済み',
unsaved: '未保存',
+2
View File
@@ -79,6 +79,8 @@ export const ko: Record<string, string> = {
mermaidZoomReset: '초기화',
mermaidZoomHint: '휠로 확대/축소 · 드래그로 이동 · Esc로 닫기',
refresh: '새로고침',
showInFolder: '폴더에서 표시',
refreshUnsavedConfirm: '디스크의 파일이 변경되었습니다. 새로고침하면 저장되지 않은 편집 내용이 손실됩니다. 계속하시겠습니까?',
save: '저장',
saved: '저장됨',
unsaved: '저장 안 됨',
+2
View File
@@ -85,6 +85,8 @@ export const nl: Record<string, string> = {
mermaidZoomReset: 'Resetten',
mermaidZoomHint: 'Scrollen om te zoomen · slepen om te pannen · Esc om te sluiten',
refresh: 'Vernieuwen',
showInFolder: 'In map tonen',
refreshUnsavedConfirm: 'Het bestand is op schijf gewijzigd. Vernieuwen gooit niet-opgeslagen wijzigingen weg. Doorgaan?',
save: 'Opslaan',
saved: 'Opgeslagen',
unsaved: 'Niet opgeslagen',
+2
View File
@@ -89,6 +89,8 @@ export const pl: Record<string, string> = {
mermaidZoomReset: 'Resetuj',
mermaidZoomHint: 'Kółko: powiększenie · przeciąganie: panowanie · Esc: zamknięcie',
refresh: 'Odśwież',
showInFolder: 'Pokaż w folderze',
refreshUnsavedConfirm: 'Plik zmienił się na dysku. Odświeżenie odrzuci niezapisane zmiany. Kontynuować?',
save: 'Zapisz',
saved: 'Zapisano',
unsaved: 'Niezapisane',
+2
View File
@@ -70,6 +70,8 @@ export const pt: Record<string, string> = {
mermaidZoomReset: 'Redefinir',
mermaidZoomHint: 'Rolar para ampliar · arrastar para mover · Esc para fechar',
refresh: 'Atualizar',
showInFolder: 'Mostrar na pasta',
refreshUnsavedConfirm: 'O arquivo mudou no disco. Atualizar descartará edições não salvas. Continuar?',
save: 'Salvar',
saved: 'Salvo',
unsaved: 'Não salvo',
+2
View File
@@ -83,6 +83,8 @@ export const ru: Record<string, string> = {
mermaidZoomReset: 'Сбросить',
mermaidZoomHint: 'Колесо — масштаб · перетаскивание — панорама · Esc — закрыть',
refresh: 'Обновить',
showInFolder: 'Показать в папке',
refreshUnsavedConfirm: 'Файл изменился на диске. Обновление отбросит несохранённые изменения. Продолжить?',
save: 'Сохранить',
saved: 'Сохранено',
unsaved: 'Не сохранено',
+2
View File
@@ -70,6 +70,8 @@ export const sv: Record<string, string> = {
mermaidZoomReset: 'Återställ',
mermaidZoomHint: 'Rulla för att zooma · dra för att panorera · Esc för att stänga',
refresh: 'Uppdatera',
showInFolder: 'Visa i mapp',
refreshUnsavedConfirm: 'Filen ändrades på disken. Uppdatering kastar osparade ändringar. Fortsätta?',
save: 'Spara',
saved: 'Sparad',
unsaved: 'Osparad',
+2
View File
@@ -87,6 +87,8 @@ export const th: Record<string, string> = {
mermaidZoomReset: 'รีเซ็ต',
mermaidZoomHint: 'เลื่อนเพื่อซูม · ลากเพื่อแพน · Esc เพื่อปิด',
refresh: 'รีเฟรช',
showInFolder: 'แสดงในโฟลเดอร์',
refreshUnsavedConfirm: 'ไฟล์เปลี่ยนบนดิสก์ การรีเฟรชจะทิ้งการแก้ไขที่ยังไม่ได้บันทึก ดำเนินการต่อหรือไม่',
save: 'บันทึก',
saved: 'บันทึกแล้ว',
unsaved: 'ยังไม่ได้บันทึก',
+2
View File
@@ -87,6 +87,8 @@ export const tr: Record<string, string> = {
mermaidZoomReset: 'Sıfırla',
mermaidZoomHint: 'Yakınlaştırmak için kaydır · taşımak için sürükle · kapatmak için Esc',
refresh: 'Yenile',
showInFolder: 'Klasörde göster',
refreshUnsavedConfirm: 'Dosya diskte değişti. Yenileme kaydedilmemiş düzenlemeleri atar. Devam edilsin mi?',
save: 'Kaydet',
saved: 'Kaydedildi',
unsaved: 'Kaydedilmedi',
+2
View File
@@ -87,6 +87,8 @@ export const vi: Record<string, string> = {
mermaidZoomReset: 'Đặt lại',
mermaidZoomHint: 'Cuộn để zoom · kéo để di chuyển · Esc để đóng',
refresh: 'Làm mới',
showInFolder: 'Hiển thị trong thư mục',
refreshUnsavedConfirm: 'Tệp đã thay đổi trên đĩa. Làm mới sẽ loại bỏ các chỉnh sửa chưa lưu. Tiếp tục?',
save: 'Lưu',
saved: 'Đã lưu',
unsaved: 'Chưa lưu',
+2
View File
@@ -102,6 +102,8 @@ export const zhHK: Record<string, string> = {
mermaidZoomReset: '重設',
mermaidZoomHint: '滾輪縮放 · 拖曳平移 · Esc 關閉',
refresh: '重新整理',
showInFolder: '在文件夾中顯示',
refreshUnsavedConfirm: '檔案已在磁碟上變更。重新整理會丟失尚未儲存的編輯。繼續?',
save: '儲存',
saved: '已儲存',
unsaved: '未儲存',
+2
View File
@@ -102,6 +102,8 @@ export const zhMO: Record<string, string> = {
mermaidZoomReset: '重設',
mermaidZoomHint: '滾輪縮放 · 拖曳平移 · Esc 關閉',
refresh: '重新整理',
showInFolder: '在文件夾中顯示',
refreshUnsavedConfirm: '檔案已在磁碟上變更。重新整理會丟失尚未儲存的編輯。繼續?',
save: '儲存',
saved: '已儲存',
unsaved: '未儲存',
+2
View File
@@ -102,6 +102,8 @@ export const zhTW: Record<string, string> = {
mermaidZoomReset: '重設',
mermaidZoomHint: '滾輪縮放 · 拖曳平移 · Esc 關閉',
refresh: '重新整理',
showInFolder: '在資料夾中顯示',
refreshUnsavedConfirm: '檔案已在磁碟上變更。重新整理會丟失尚未儲存的編輯。繼續?',
save: '儲存',
saved: '已儲存',
unsaved: '未儲存',
+5 -8
View File
@@ -533,8 +533,10 @@ function buildApi(
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
// Some servers answer HEAD with 405/501; retry once as GET (the
// body is discarded — only the headers matter).
let retriedFromHeadRejection = false
if (response.status === 405 || response.status === 501) {
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
retriedFromHeadRejection = true
}
// Some servers (e.g. aliyun consoles) answer HEAD without the
// X-Frame-Options / CSP headers that only their GET response
@@ -542,9 +544,11 @@ function buildApi(
// would wrongly report the site as embeddable and the plain iframe
// would surface the browser's misleading "refused to connect".
// Retry once as GET when both signals are absent (body discarded).
// A 405/501 retry already fetched the GET response, so the signals
// are either there or genuinely absent — another GET adds nothing.
const hasEmbedSignals = response.headers.get('content-security-policy') !== null
|| response.headers.get('x-frame-options') !== null
if (!hasEmbedSignals && response.status !== 405 && response.status !== 501) {
if (!hasEmbedSignals && !retriedFromHeadRejection && response.status !== 405 && response.status !== 501) {
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
}
const csp = response.headers.get('content-security-policy')
@@ -1006,13 +1010,6 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
agentListWss.close()
agentOpenWss.close()
}, 'dsh-better-sidebar: teardown')
// Close all UI terminals of a session when the conversation is deleted —
// terminals follow the session, not the reconnect grace. Agent terminals
// are owned by the agent lifecycle and are not touched here.
ctx.on('session/disposed', (session) => {
try { ptyManager?.closeSession(session?.id) } catch {}
}, { global: true })
}
/** Push queued `sidebar_open` requests for one session to a connected view. */
-6
View File
@@ -243,12 +243,6 @@ export class PtyManager {
}
}
/** Close every terminal of one session (the conversation was deleted —
* terminals must follow the session, not linger until the grace expires). */
closeSession(sessionId: string): void {
for (const key of this.keysOf(sessionId)) this.close(key)
}
/** Close every terminal (plugin teardown). */
disposeAll(): void {
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
+1
View File
@@ -131,6 +131,7 @@ describe('built-in tab registrations', () => {
'browserInterceptLinks',
'browserInterceptHttp',
'browserInterceptHttps',
'browserAllowedLoopback',
])
for (const toggle of toggles) {
expect(toggle.title).toBeDefined()
+1
View File
@@ -80,6 +80,7 @@ function setup(initialMode: 'preview' | 'edit' = 'preview', dirty = false): {
const ctx = {
betterSidebar: service,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
get: (name: string) => name === 'betterSidebar' ? service : undefined,
} as unknown as Context
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
+1
View File
@@ -99,6 +99,7 @@ function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
+8 -1
View File
@@ -79,7 +79,14 @@ describe('PowerShell installer entry points', () => {
const source = readFileSync(resolve(ROOT, file), 'utf8')
expect(source, file).not.toMatch(new RegExp(`irm [^\\r\\n]*${INSTALLER_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\\r\\n]*\\|\\s*iex`, 'i'))
expect(source, file).not.toMatch(/\[scriptblock\]::Create\(\(irm\b/i)
expect(source.match(/\.TrimStart\(\[char\]0xFEFF\)/g)?.length, file).toBeGreaterThanOrEqual(2)
// Where remote invocation IS documented, it must be BOM-safe: the
// READMEs switched to the prompt-based install flow and no longer
// document `irm` at all, so the TrimStart gate applies to the script
// (whose usage comment demonstrates the safe pattern).
const documentsInvocation = /\birm\b|\$script\s*=|\[scriptblock\]/.test(source)
if (documentsInvocation) {
expect(source.match(/\.TrimStart\(\[char\]0xFEFF\)/g)?.length, file).toBeGreaterThanOrEqual(2)
}
}
})
+4 -1
View File
@@ -60,7 +60,10 @@ function setup(): {
.flatMap(leaf => leaf.tabs)
.find(candidate => candidate.type === 'editor')!
const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' }
const ctx = { betterSidebar: service } as unknown as Context
const ctx = {
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
} as unknown as Context
return { store, ctx, tab }
}
+1 -1
View File
@@ -94,6 +94,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
})
})
+1
View File
@@ -907,6 +907,7 @@ describe('side card settings routes', () => {
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
browserAllowedLoopback: '',
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
+4 -1
View File
@@ -871,7 +871,10 @@ describe('v0.12.0 store additions', () => {
// timers BOTH writes are pending and both land when they fire.
expect(timers.size).toBe(2)
for (const [, fn] of [...timers]) fn()
expect(writes).toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b'])
// Each persist also syncs the shared cross-session width key (PR #36).
expect(writes.filter(key => key !== 'dsh-sidebar:v1:width'))
.toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b'])
expect(writes).toContain('dsh-sidebar:v1:width')
} finally {
delete g.window
delete g.localStorage