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:
@@ -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>
|
||||
|
||||
@@ -88,6 +88,8 @@ export const ar: Record<string, string> = {
|
||||
mermaidZoomReset: 'إعادة تعيين',
|
||||
mermaidZoomHint: 'العجلة للتقريب · السحب للتحريك · Esc للإغلاق',
|
||||
refresh: 'تحديث',
|
||||
showInFolder: 'إظهار في المجلد',
|
||||
refreshUnsavedConfirm: 'تغيّر الملف على القرص. سيؤدي التحديث إلى فقدان التعديلات غير المحفوظة. متابعة؟',
|
||||
save: 'حفظ',
|
||||
saved: 'تم الحفظ',
|
||||
unsaved: 'غير محفوظ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -87,6 +87,8 @@ export const hi: Record<string, string> = {
|
||||
mermaidZoomReset: 'रीसेट',
|
||||
mermaidZoomHint: 'स्क्रॉल से ज़ूम · खींचें पैन करने · Esc से बंद',
|
||||
refresh: 'ताज़ा करें',
|
||||
showInFolder: 'फ़ोल्डर में दिखाएँ',
|
||||
refreshUnsavedConfirm: 'डिस्क पर फ़ाइल बदल गई है। रीफ़्रेश करने पर असहेज किए गए संपादन खो जाएँगे। जारी रखें?',
|
||||
save: 'सहेजें',
|
||||
saved: 'सहेजा गया',
|
||||
unsaved: 'असहेजित',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -87,6 +87,8 @@ export const ja: Record<string, string> = {
|
||||
mermaidZoomReset: 'リセット',
|
||||
mermaidZoomHint: 'スクロールで拡大 ・ ドラッグで移動 ・ Esc で閉じる',
|
||||
refresh: '更新',
|
||||
showInFolder: 'フォルダーで表示',
|
||||
refreshUnsavedConfirm: 'ディスク上のファイルが変更されました。更新すると未保存の編集が失われます。続行しますか?',
|
||||
save: '保存',
|
||||
saved: '保存済み',
|
||||
unsaved: '未保存',
|
||||
|
||||
@@ -79,6 +79,8 @@ export const ko: Record<string, string> = {
|
||||
mermaidZoomReset: '초기화',
|
||||
mermaidZoomHint: '휠로 확대/축소 · 드래그로 이동 · Esc로 닫기',
|
||||
refresh: '새로고침',
|
||||
showInFolder: '폴더에서 표시',
|
||||
refreshUnsavedConfirm: '디스크의 파일이 변경되었습니다. 새로고침하면 저장되지 않은 편집 내용이 손실됩니다. 계속하시겠습니까?',
|
||||
save: '저장',
|
||||
saved: '저장됨',
|
||||
unsaved: '저장 안 됨',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -83,6 +83,8 @@ export const ru: Record<string, string> = {
|
||||
mermaidZoomReset: 'Сбросить',
|
||||
mermaidZoomHint: 'Колесо — масштаб · перетаскивание — панорама · Esc — закрыть',
|
||||
refresh: 'Обновить',
|
||||
showInFolder: 'Показать в папке',
|
||||
refreshUnsavedConfirm: 'Файл изменился на диске. Обновление отбросит несохранённые изменения. Продолжить?',
|
||||
save: 'Сохранить',
|
||||
saved: 'Сохранено',
|
||||
unsaved: 'Не сохранено',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -87,6 +87,8 @@ export const th: Record<string, string> = {
|
||||
mermaidZoomReset: 'รีเซ็ต',
|
||||
mermaidZoomHint: 'เลื่อนเพื่อซูม · ลากเพื่อแพน · Esc เพื่อปิด',
|
||||
refresh: 'รีเฟรช',
|
||||
showInFolder: 'แสดงในโฟลเดอร์',
|
||||
refreshUnsavedConfirm: 'ไฟล์เปลี่ยนบนดิสก์ การรีเฟรชจะทิ้งการแก้ไขที่ยังไม่ได้บันทึก ดำเนินการต่อหรือไม่',
|
||||
save: 'บันทึก',
|
||||
saved: 'บันทึกแล้ว',
|
||||
unsaved: 'ยังไม่ได้บันทึก',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -102,6 +102,8 @@ export const zhHK: Record<string, string> = {
|
||||
mermaidZoomReset: '重設',
|
||||
mermaidZoomHint: '滾輪縮放 · 拖曳平移 · Esc 關閉',
|
||||
refresh: '重新整理',
|
||||
showInFolder: '在文件夾中顯示',
|
||||
refreshUnsavedConfirm: '檔案已在磁碟上變更。重新整理會丟失尚未儲存的編輯。繼續?',
|
||||
save: '儲存',
|
||||
saved: '已儲存',
|
||||
unsaved: '未儲存',
|
||||
|
||||
@@ -102,6 +102,8 @@ export const zhMO: Record<string, string> = {
|
||||
mermaidZoomReset: '重設',
|
||||
mermaidZoomHint: '滾輪縮放 · 拖曳平移 · Esc 關閉',
|
||||
refresh: '重新整理',
|
||||
showInFolder: '在文件夾中顯示',
|
||||
refreshUnsavedConfirm: '檔案已在磁碟上變更。重新整理會丟失尚未儲存的編輯。繼續?',
|
||||
save: '儲存',
|
||||
saved: '已儲存',
|
||||
unsaved: '未儲存',
|
||||
|
||||
@@ -102,6 +102,8 @@ export const zhTW: Record<string, string> = {
|
||||
mermaidZoomReset: '重設',
|
||||
mermaidZoomHint: '滾輪縮放 · 拖曳平移 · Esc 關閉',
|
||||
refresh: '重新整理',
|
||||
showInFolder: '在資料夾中顯示',
|
||||
refreshUnsavedConfirm: '檔案已在磁碟上變更。重新整理會丟失尚未儲存的編輯。繼續?',
|
||||
save: '儲存',
|
||||
saved: '已儲存',
|
||||
unsaved: '未儲存',
|
||||
|
||||
+5
-8
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -131,6 +131,7 @@ describe('built-in tab registrations', () => {
|
||||
'browserInterceptLinks',
|
||||
'browserInterceptHttp',
|
||||
'browserInterceptHttps',
|
||||
'browserAllowedLoopback',
|
||||
])
|
||||
for (const toggle of toggles) {
|
||||
expect(toggle.title).toBeDefined()
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 })) })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user