@wjj-8283/dsh-temp-workspace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/README.zh.md +207 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +827 -0
- package/lib/index.js +1446 -0
- package/package.json +68 -0
- package/src/client.js +816 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
// @wjj-8283/dsh-temp-workspace — built by build.mjs from src/client.js; do not edit this file.
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: "@wjj-8283/dsh-temp-workspace",
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
var module = { exports: {} };
|
|
6
|
+
var exports = module.exports;
|
|
7
|
+
|
|
8
|
+
// @wjj-8283/dsh-temp-workspace — browser half SOURCE (editable).
|
|
9
|
+
//
|
|
10
|
+
// Three responsibilities:
|
|
11
|
+
// 1. Inject a small icon button beside the sidebar's "Add workspace" (+)
|
|
12
|
+
// button. The workspace section header has no pluggable header-action
|
|
13
|
+
// slot, so (like dsh-workspace-auto-approval) we decorate the live DOM
|
|
14
|
+
// with a MutationObserver — React re-renders drop our node and the
|
|
15
|
+
// observer puts it back.
|
|
16
|
+
// 2. Register a Settings -> Plugins card (settings.plugin.item, keyed by the
|
|
17
|
+
// settings namespace) that lets the user choose delete timing (immediate
|
|
18
|
+
// vs. delayed + a delay) and whether to confirm before deleting.
|
|
19
|
+
// 3. On boot, if there are temporary workspaces held for confirmation and
|
|
20
|
+
// confirmBeforeDelete is on, show a confirm dialog before the host purges
|
|
21
|
+
// them. There is no generic "ask the human" API for a browser plugin, so
|
|
22
|
+
// this renders its own fixed-position overlay via a dedicated
|
|
23
|
+
// react-dom/client root (inline-styled, so it renders regardless of the
|
|
24
|
+
// app's CSS-module scope).
|
|
25
|
+
//
|
|
26
|
+
// Every data read/write rides the plugin's own fenced /temp-workspace/api route
|
|
27
|
+
// (same idiom as dsh-workspace-auto-approval's /config contract). The host
|
|
28
|
+
// holds the authoritative settings; the card just GETs/POSTs them.
|
|
29
|
+
//
|
|
30
|
+
// This file is plain CJS and is wrapped by ./build.mjs into a
|
|
31
|
+
// window.__ModuleLoader__.load({ id, factory }) bundle at ./lib/client.js. In
|
|
32
|
+
// that bundle `require` is the shell module loader, so react/react-dom/client
|
|
33
|
+
// resolve without bundling.
|
|
34
|
+
const react = require('react')
|
|
35
|
+
const reactDOMClient = require('react-dom/client')
|
|
36
|
+
|
|
37
|
+
const SETTINGS_NS = 'dsh-temp-workspace'
|
|
38
|
+
|
|
39
|
+
const ICON_SVG =
|
|
40
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;flex:none">' +
|
|
41
|
+
'<path d="M6 3h12M6 21h12" />' +
|
|
42
|
+
'<path d="M18 3v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6V3" />' +
|
|
43
|
+
'<path d="M18 21v-3a6 6 0 0 0-6-6 6 6 0 0 0-6 6v3" />' +
|
|
44
|
+
'</svg>'
|
|
45
|
+
|
|
46
|
+
// A "keep/pin" glyph for the permanent-keep row button.
|
|
47
|
+
const KEEP_SVG =
|
|
48
|
+
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;flex:none">' +
|
|
49
|
+
'<path d="M9 2h6M9 2v7l-3 4h12l-3-4V2" />' +
|
|
50
|
+
'<path d="M12 13v9" />' +
|
|
51
|
+
'</svg>'
|
|
52
|
+
|
|
53
|
+
const ICON_CLASS = 'dsh-temp-workspace-icon'
|
|
54
|
+
const BUTTON_CLASS = 'dsh-temp-workspace-button'
|
|
55
|
+
const ADD_LABELS = ['Add workspace', '添加工作区']
|
|
56
|
+
|
|
57
|
+
// Required cordis services: the browser workspace runtime (to open a New
|
|
58
|
+
// Session in the created workspace), the slot registry (for the settings
|
|
59
|
+
// card), and the locale seat (bilingual copy).
|
|
60
|
+
const inject = ['workspaces', 'slots', 'locale']
|
|
61
|
+
|
|
62
|
+
function apply(ctx) {
|
|
63
|
+
const workspaces = ctx.get ? ctx.get('workspaces') : ctx.workspaces
|
|
64
|
+
const slots = ctx.get ? ctx.get('slots') : ctx.slots
|
|
65
|
+
const locale = ctx.get ? ctx.get('locale') : ctx.locale
|
|
66
|
+
|
|
67
|
+
// Keep the module-level activeLocale in sync so every UI text follows the
|
|
68
|
+
// user's language, and re-render the decorated DOM on a locale switch.
|
|
69
|
+
const syncLocale = () => {
|
|
70
|
+
try { activeLocale = locale && locale.getLocale ? (locale.getLocale().active === 'en' ? 'en' : 'zh') : 'zh' } catch { activeLocale = 'zh' }
|
|
71
|
+
}
|
|
72
|
+
syncLocale()
|
|
73
|
+
if (locale && typeof locale.subscribe === 'function') {
|
|
74
|
+
ctx.effect(() => {
|
|
75
|
+
const off = locale.subscribe(() => {
|
|
76
|
+
syncLocale()
|
|
77
|
+
insertIcon()
|
|
78
|
+
injectRowButtons(true)
|
|
79
|
+
})
|
|
80
|
+
return () => { if (off) off() }
|
|
81
|
+
}, 'dsh-temp-workspace: locale sync')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── inject card/dialog CSS once ──────────────────────────────────────────
|
|
85
|
+
ctx.effect(() => injectStyle(), 'dsh-temp-workspace: styles')
|
|
86
|
+
|
|
87
|
+
// ── icon injection (unchanged from the first version) ────────────────────
|
|
88
|
+
let inserting = false
|
|
89
|
+
let scheduled = null
|
|
90
|
+
|
|
91
|
+
async function createTempWorkspace() {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch('/temp-workspace/api/create', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'content-type': 'application/json' },
|
|
96
|
+
body: '{}',
|
|
97
|
+
})
|
|
98
|
+
const parsed = (await res.json().catch(() => null)) || {}
|
|
99
|
+
if (!res.ok || parsed.ok !== true) {
|
|
100
|
+
throw new Error(parsed?.error?.message || `HTTP ${res.status}`)
|
|
101
|
+
}
|
|
102
|
+
const workspace = parsed.value?.workspace
|
|
103
|
+
if (workspace === undefined || workspace === null) {
|
|
104
|
+
throw new Error('create returned no workspace')
|
|
105
|
+
}
|
|
106
|
+
if (workspaces && typeof workspaces.refresh === 'function') {
|
|
107
|
+
await workspaces.refresh()
|
|
108
|
+
}
|
|
109
|
+
if (workspaces) workspaces.startSession(workspace.workspaceId)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('[temp-workspace] create failed', error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── native-delete hook (client half) ─────────────────────────────────────
|
|
116
|
+
// DSH's native workspace delete retains the directory and EVERY session log,
|
|
117
|
+
// so deleting a temp workspace from the sidebar would leave its conversations
|
|
118
|
+
// behind — they reappear as "Ungrouped" after the next boot. When the deleted
|
|
119
|
+
// workspace is one of our temp workspaces, route it through the plugin's own
|
|
120
|
+
// delete route, which reaps the conversations + throwaway dir + marker entry.
|
|
121
|
+
// The host-side registry hook does the same after a host restart; this client
|
|
122
|
+
// hook covers the running session (client code reloads on a page refresh).
|
|
123
|
+
if (workspaces && typeof workspaces.delete === 'function') {
|
|
124
|
+
const nativeDelete = workspaces.delete.bind(workspaces)
|
|
125
|
+
workspaces.delete = async (workspaceId) => {
|
|
126
|
+
const result = await nativeDelete(workspaceId)
|
|
127
|
+
try {
|
|
128
|
+
// Refresh the temp marker list first: the native delete removes the
|
|
129
|
+
// registry record but not the marker entry, so it will still be present
|
|
130
|
+
// (until either this hook or the host hook reaps it).
|
|
131
|
+
await fetchTempEntries()
|
|
132
|
+
if (tempEntries.some((entry) => entry.workspaceId === workspaceId)) {
|
|
133
|
+
await post('/temp-workspace/api/delete', { workspaceId })
|
|
134
|
+
if (workspaces && typeof workspaces.refresh === 'function') {
|
|
135
|
+
await workspaces.refresh()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('[temp-workspace] delete cleanup failed', error)
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findAddButton() {
|
|
146
|
+
const buttons = document.querySelectorAll('button')
|
|
147
|
+
for (const button of buttons) {
|
|
148
|
+
const label = button.getAttribute('aria-label')
|
|
149
|
+
if (label !== null && ADD_LABELS.includes(label)) return button
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function insertIcon() {
|
|
155
|
+
if (inserting) return
|
|
156
|
+
const add = findAddButton()
|
|
157
|
+
if (add === null) return
|
|
158
|
+
const container = add.parentElement
|
|
159
|
+
if (container !== null) {
|
|
160
|
+
container.style.maxWidth = 'none'
|
|
161
|
+
container.style.overflow = 'visible'
|
|
162
|
+
}
|
|
163
|
+
const title = localeText('iconTitle', 'Temporary workspace')
|
|
164
|
+
const next = add.nextElementSibling
|
|
165
|
+
if (next !== null && next.classList.contains(BUTTON_CLASS)) {
|
|
166
|
+
// Locale changed: refresh the existing button's accessible text.
|
|
167
|
+
next.setAttribute('aria-label', title)
|
|
168
|
+
next.setAttribute('title', title)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
inserting = true
|
|
173
|
+
try {
|
|
174
|
+
const button = document.createElement('button')
|
|
175
|
+
button.type = 'button'
|
|
176
|
+
button.className = BUTTON_CLASS
|
|
177
|
+
button.setAttribute('aria-label', title)
|
|
178
|
+
button.setAttribute('title', title)
|
|
179
|
+
button.innerHTML =
|
|
180
|
+
'<span class="' + ICON_CLASS + '" aria-hidden="true">' + ICON_SVG + '</span>'
|
|
181
|
+
button.addEventListener('click', (event) => {
|
|
182
|
+
event.preventDefault()
|
|
183
|
+
event.stopPropagation()
|
|
184
|
+
void createTempWorkspace()
|
|
185
|
+
})
|
|
186
|
+
const dims = add.getBoundingClientRect()
|
|
187
|
+
const size = Math.max(dims.width, dims.height) || 28
|
|
188
|
+
button.style.width = size + 'px'
|
|
189
|
+
button.style.height = size + 'px'
|
|
190
|
+
button.style.display = 'inline-flex'
|
|
191
|
+
button.style.alignItems = 'center'
|
|
192
|
+
button.style.justifyContent = 'center'
|
|
193
|
+
button.style.flex = 'none'
|
|
194
|
+
button.style.padding = '0'
|
|
195
|
+
button.style.border = 'none'
|
|
196
|
+
button.style.background = 'transparent'
|
|
197
|
+
button.style.color = 'inherit'
|
|
198
|
+
button.style.cursor = 'pointer'
|
|
199
|
+
button.style.borderRadius = '50%'
|
|
200
|
+
button.style.marginLeft = '2px'
|
|
201
|
+
add.insertAdjacentElement('afterend', button)
|
|
202
|
+
} finally {
|
|
203
|
+
inserting = false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── per-row "permanent keep" button in temp workspace rows ────────────────
|
|
208
|
+
// A temp workspace renders as a normal workspace row (title "临时工作区") with
|
|
209
|
+
// a "+" (New Session) button in its hover actions. We inject a small permanent-
|
|
210
|
+
// keep button next to that "+" so the user can move a temp folder out of the
|
|
211
|
+
// temp area without going through the boot dialog. Temp workspaces share the
|
|
212
|
+
// title "临时工作区", which is how their rows are matched.
|
|
213
|
+
let tempEntries = [] // [{ workspaceId, path }] from /list, cached
|
|
214
|
+
let rowEntriesFetchedAt = 0
|
|
215
|
+
|
|
216
|
+
async function fetchTempEntries() {
|
|
217
|
+
try {
|
|
218
|
+
const res = await fetch('/temp-workspace/api/list', { method: 'GET' })
|
|
219
|
+
const parsed = (await res.json().catch(() => null)) || {}
|
|
220
|
+
const value = unwrapValue(parsed)
|
|
221
|
+
if (res.ok && parsed.ok === true && value && Array.isArray(value.entries)) {
|
|
222
|
+
tempEntries = value.entries.map((e) => ({ workspaceId: e.workspaceId, path: e.path }))
|
|
223
|
+
}
|
|
224
|
+
} catch (e) { /* ignore */ }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function injectRowButtons(force) {
|
|
228
|
+
// Refresh temp entries occasionally (or immediately when forced, e.g. on a
|
|
229
|
+
// locale switch) so newly created temp workspaces / re-renders are caught.
|
|
230
|
+
if (force || Date.now() - rowEntriesFetchedAt > 5000) {
|
|
231
|
+
rowEntriesFetchedAt = Date.now()
|
|
232
|
+
void fetchTempEntries()
|
|
233
|
+
}
|
|
234
|
+
if (tempEntries.length === 0) return
|
|
235
|
+
// All temp workspaces share the title "临时工作区", so the temp-titled rows
|
|
236
|
+
// correspond 1:1 (in registry/DOM order) to the marker entries. Inject the
|
|
237
|
+
// permanent-keep button next to each temp row's New Session (+).
|
|
238
|
+
const keepTitle = localeText('keepTitle', 'Keep permanently (move out of temp)')
|
|
239
|
+
const keepAria = localeText('keepAria', 'Keep permanently')
|
|
240
|
+
const rows = document.querySelectorAll('[role="treeitem"]')
|
|
241
|
+
const tempRows = []
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
// A workspace row's own title lives in its projectText block; a session
|
|
244
|
+
// row's title is also classed "title", so scope to the project text.
|
|
245
|
+
const projText = row.querySelector('[class*="projectText"]')
|
|
246
|
+
const titleEl = projText !== null ? projText.querySelector('[class*="title"]') : null
|
|
247
|
+
const title = titleEl !== null ? (titleEl.textContent || '').trim() : ''
|
|
248
|
+
if (title === '临时工作区') tempRows.push(row)
|
|
249
|
+
}
|
|
250
|
+
for (let i = 0; i < tempRows.length && i < tempEntries.length; i += 1) {
|
|
251
|
+
const row = tempRows[i]
|
|
252
|
+
const entry = tempEntries[i]
|
|
253
|
+
const actions = row.querySelector('[class*="rowActions"]')
|
|
254
|
+
if (actions === null) continue
|
|
255
|
+
const existing = actions.querySelector('.dsh-temp-workspace-keep')
|
|
256
|
+
if (existing !== null) {
|
|
257
|
+
// Locale changed: refresh the existing button's accessible text.
|
|
258
|
+
existing.setAttribute('aria-label', keepAria)
|
|
259
|
+
existing.setAttribute('title', keepTitle)
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
const plus = row.querySelector('button[aria-label*="新建会话"], button[aria-label*="New session"]') || row.querySelector('button')
|
|
263
|
+
const btn = document.createElement('button')
|
|
264
|
+
btn.type = 'button'
|
|
265
|
+
btn.className = 'dsh-temp-workspace-keep'
|
|
266
|
+
btn.setAttribute('aria-label', keepAria)
|
|
267
|
+
btn.setAttribute('title', keepTitle)
|
|
268
|
+
btn.innerHTML = KEEP_SVG
|
|
269
|
+
btn.addEventListener('click', (event) => {
|
|
270
|
+
event.preventDefault()
|
|
271
|
+
event.stopPropagation()
|
|
272
|
+
void doPermanentKeep(entry.workspaceId)
|
|
273
|
+
})
|
|
274
|
+
const dims = plus ? plus.getBoundingClientRect() : null
|
|
275
|
+
const size = Math.max(dims ? dims.width : 16, dims ? dims.height : 16) || 16
|
|
276
|
+
btn.style.width = size + 'px'
|
|
277
|
+
btn.style.height = size + 'px'
|
|
278
|
+
btn.style.display = 'inline-flex'
|
|
279
|
+
btn.style.alignItems = 'center'
|
|
280
|
+
btn.style.justifyContent = 'center'
|
|
281
|
+
btn.style.flex = 'none'
|
|
282
|
+
btn.style.padding = '0'
|
|
283
|
+
btn.style.border = 'none'
|
|
284
|
+
btn.style.background = 'transparent'
|
|
285
|
+
btn.style.color = 'inherit'
|
|
286
|
+
btn.style.cursor = 'pointer'
|
|
287
|
+
btn.style.borderRadius = '4px'
|
|
288
|
+
btn.style.marginLeft = '2px'
|
|
289
|
+
actions.appendChild(btn)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function schedule() {
|
|
294
|
+
if (scheduled !== null) return
|
|
295
|
+
scheduled = requestAnimationFrame(() => {
|
|
296
|
+
scheduled = null
|
|
297
|
+
insertIcon()
|
|
298
|
+
injectRowButtons()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const observer = new MutationObserver(schedule)
|
|
303
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
304
|
+
insertIcon()
|
|
305
|
+
|
|
306
|
+
// Fetch temp entries and inject row buttons once on boot (and refresh via the
|
|
307
|
+
// observer/5s debounce once the sidebar re-renders).
|
|
308
|
+
void fetchTempEntries().then(() => injectRowButtons())
|
|
309
|
+
|
|
310
|
+
// ── boot confirm popup ───────────────────────────────────────────────────
|
|
311
|
+
// Ask the host once whether any temp workspace is held for confirmation. If
|
|
312
|
+
// yes (and the setting is on), render a Modal with Delete / Keep. This keeps
|
|
313
|
+
// the marker durable: an unanswered dialog leaves the workspaces intact for
|
|
314
|
+
// the next boot.
|
|
315
|
+
const disposers = []
|
|
316
|
+
|
|
317
|
+
let confirmOpen = false
|
|
318
|
+
let bootAnswered = false
|
|
319
|
+
|
|
320
|
+
async function confirmPending() {
|
|
321
|
+
// Once the user has answered the boot prompt (keep/delete/permanent-keep),
|
|
322
|
+
// never re-open it for the rest of this page session.
|
|
323
|
+
if (bootAnswered) return
|
|
324
|
+
try {
|
|
325
|
+
const res = await fetch('/temp-workspace/api/pending', { method: 'GET' })
|
|
326
|
+
const parsed = (await res.json().catch(() => null)) || {}
|
|
327
|
+
if (!res.ok || parsed.ok !== true) throw new Error(parsed?.error?.message || `HTTP ${res.status}`)
|
|
328
|
+
// The route wraps the handler result as { ok, value }; some host builds
|
|
329
|
+
// nested it one level deeper, so unwrap defensively.
|
|
330
|
+
const value = unwrapValue(parsed)
|
|
331
|
+
if (!value || value.pendingCount <= 0) return
|
|
332
|
+
if (value.confirmBeforeDelete !== true) return
|
|
333
|
+
|
|
334
|
+
// Wait for the delayed deletion window before prompting, so the user
|
|
335
|
+
// still has the configured grace period to avert the popup.
|
|
336
|
+
const waitMs = Math.max(0, (value.deleteAt ?? 0) - Date.now())
|
|
337
|
+
if (waitMs > 0) {
|
|
338
|
+
setTimeout(() => { void confirmPending() }, Math.min(waitMs, 60_000))
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
// Only one confirm overlay at a time — the boot retries can otherwise
|
|
342
|
+
// stack a second dialog next to the first.
|
|
343
|
+
if (confirmOpen) return
|
|
344
|
+
confirmOpen = true
|
|
345
|
+
renderConfirm(value)
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error('[temp-workspace] confirmPending failed', error)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderConfirm(value) {
|
|
352
|
+
const count = value?.pendingCount ?? value?.pending?.length ?? 0
|
|
353
|
+
const names = (value?.pending ?? [])
|
|
354
|
+
.map((p) => (p.path ? String(p.path).split(/[/\\]/).filter(Boolean).pop() : p.workspaceId))
|
|
355
|
+
.filter(Boolean)
|
|
356
|
+
|
|
357
|
+
const body = names.length > 0
|
|
358
|
+
? react.createElement('ul', { style: { margin: '0 0 0 18px', padding: 0 } },
|
|
359
|
+
names.map((n) => react.createElement('li', { key: n, style: { fontSize: 12, lineHeight: 1.7 } }, n)))
|
|
360
|
+
: null
|
|
361
|
+
|
|
362
|
+
const dialog = mountDialog({
|
|
363
|
+
title: localeText('confirmTitle', 'Temporary workspaces pending'),
|
|
364
|
+
description: localeText('confirmDesc', `${count} temporary workspace(s) will be deleted together with their conversations unless you keep them.`, { n: count }),
|
|
365
|
+
onMask: () => { bootAnswered = true; dialog.close(); void post('/temp-workspace/api/keep') },
|
|
366
|
+
onClose: () => { confirmOpen = false },
|
|
367
|
+
buttons: [
|
|
368
|
+
{ label: localeText('tmpKeep', 'Keep temporarily'), onClick: () => { bootAnswered = true; dialog.close(); void post('/temp-workspace/api/keep') } },
|
|
369
|
+
{ label: localeText('delete', 'Delete'), primary: true, onClick: () => { bootAnswered = true; dialog.close(); void post('/temp-workspace/api/confirm') } },
|
|
370
|
+
],
|
|
371
|
+
children: body,
|
|
372
|
+
}, { autoFocusFirst: true })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Permanent keep for one temp workspace: pick the destination folder, then ask
|
|
376
|
+
// the host to move that workspace's files in and de-temp it.
|
|
377
|
+
const doPermanentKeep = async (workspaceId) => {
|
|
378
|
+
if (!workspaces || typeof workspaces.pickDirectory !== 'function') {
|
|
379
|
+
console.error('[temp-workspace] pickDirectory unavailable')
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const target = await workspaces.pickDirectory()
|
|
384
|
+
if (target === null || target === undefined || target === '') return
|
|
385
|
+
const payload = workspaceId !== undefined ? { target, workspaceId } : { target }
|
|
386
|
+
const ok = await post('/temp-workspace/api/permanentKeep', payload)
|
|
387
|
+
if (ok) { mountRestartNotice(); return }
|
|
388
|
+
console.error('[temp-workspace] permanent keep failed')
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error('[temp-workspace] permanent keep failed', error)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A short success dialog after a permanent keep, suggesting a restart.
|
|
395
|
+
function mountRestartNotice() {
|
|
396
|
+
// Non-dismissible: the user must restart for the migration to take effect,
|
|
397
|
+
// so the only action is "Restart now" — a real host restart.
|
|
398
|
+
mountDialog({
|
|
399
|
+
title: localeText('movedTitle', 'Workspace moved'),
|
|
400
|
+
description: localeText('movedDesc', 'The workspace(s) were moved out of the temporary area and are now permanent. For the migrated conversations to appear under the new workspace, restart the Harness now.'),
|
|
401
|
+
dismissible: false,
|
|
402
|
+
buttons: [
|
|
403
|
+
{
|
|
404
|
+
label: localeText('restartNow', 'Restart now'),
|
|
405
|
+
primary: true,
|
|
406
|
+
onClick: () => {
|
|
407
|
+
// Real host-level restart (detached helper respawns the DSH entry),
|
|
408
|
+
// not just a page reload — so the registry re-indexes.
|
|
409
|
+
void fetch('/temp-workspace/api/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
|
410
|
+
.catch((error) => console.error('[temp-workspace] restart failed', error))
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function post(url, payload) {
|
|
418
|
+
try {
|
|
419
|
+
const res = await fetch(url, {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
headers: { 'content-type': 'application/json' },
|
|
422
|
+
body: JSON.stringify(payload ?? {}),
|
|
423
|
+
})
|
|
424
|
+
const parsed = (await res.json().catch(() => null)) || {}
|
|
425
|
+
return res.ok && parsed.ok === true
|
|
426
|
+
} catch (error) {
|
|
427
|
+
console.error('[temp-workspace] ' + url + ' failed', error)
|
|
428
|
+
return false
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
void confirmPending()
|
|
433
|
+
|
|
434
|
+
// Re-check a few times after the first tick in case the host registers the
|
|
435
|
+
// pending set asynchronously (settings registration awaits the loader import
|
|
436
|
+
// and can outlive the client's first poll). confirmPending re-shows the popup
|
|
437
|
+
// only once, so an early empty read is harmless; these retries close the gap.
|
|
438
|
+
const RETRY_DELAYS = [1500, 3000, 6000, 12000]
|
|
439
|
+
const retries = RETRY_DELAYS.map((delay) => setTimeout(() => { void confirmPending() }, delay))
|
|
440
|
+
disposers.push(() => { for (const t of retries) clearTimeout(t) })
|
|
441
|
+
|
|
442
|
+
// ── Settings -> Plugins card ────────────────────────────────────────────
|
|
443
|
+
// Self-contained React card, keyed by the settings namespace, reading and
|
|
444
|
+
// writing its own settings via the host route (identical to the auto-approval
|
|
445
|
+
// card). It manages state directly rather than through the tab's inject face.
|
|
446
|
+
const SETTINGS_LIMIT = 24 * 3600 // max delay seconds (24h)
|
|
447
|
+
|
|
448
|
+
function SettingsCard() {
|
|
449
|
+
const [open, setOpen] = react.useState(false)
|
|
450
|
+
const [state, setState] = react.useState({
|
|
451
|
+
deleteMode: 'immediate',
|
|
452
|
+
deleteDelay: 3600,
|
|
453
|
+
confirmBeforeDelete: true,
|
|
454
|
+
loading: true,
|
|
455
|
+
saving: false,
|
|
456
|
+
status: '',
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
react.useEffect(() => {
|
|
460
|
+
let alive = true
|
|
461
|
+
fetch('/temp-workspace/api/config', { method: 'GET' })
|
|
462
|
+
.then((response) => response.json())
|
|
463
|
+
.then((data) => {
|
|
464
|
+
if (!alive) return
|
|
465
|
+
const v = unwrapValue(data)
|
|
466
|
+
setState((current) => ({
|
|
467
|
+
...current,
|
|
468
|
+
loading: false,
|
|
469
|
+
deleteMode: v?.deleteMode ?? current.deleteMode,
|
|
470
|
+
deleteDelay: Number.isFinite(Number(v?.deleteDelay)) ? Number(v.deleteDelay) : current.deleteDelay,
|
|
471
|
+
confirmBeforeDelete: v?.confirmBeforeDelete ?? current.confirmBeforeDelete,
|
|
472
|
+
status: data && data.ok === true ? '' : localeText('loadFail', 'Load failed'),
|
|
473
|
+
}))
|
|
474
|
+
})
|
|
475
|
+
.catch(() => { if (alive) setState((current) => ({ ...current, loading: false, status: localeText('loadFail', 'Load failed') })) })
|
|
476
|
+
|
|
477
|
+
const off = locale && typeof locale.subscribe === 'function'
|
|
478
|
+
? locale.subscribe(() => setState((current) => ({ ...current, revision: (current.revision || 0) + 1 })))
|
|
479
|
+
: null
|
|
480
|
+
return () => { alive = false; if (off) off() }
|
|
481
|
+
}, [])
|
|
482
|
+
|
|
483
|
+
const busy = state.loading || state.saving
|
|
484
|
+
const valid = state.deleteMode === 'immediate' || state.deleteMode === 'delayed'
|
|
485
|
+
const delayText = String(state.deleteDelay)
|
|
486
|
+
|
|
487
|
+
const update = (patch, successText) => {
|
|
488
|
+
setState((current) => ({ ...current, saving: true, status: localeText('saving', 'Saving…') }))
|
|
489
|
+
fetch('/temp-workspace/api/config', {
|
|
490
|
+
method: 'POST',
|
|
491
|
+
headers: { 'content-type': 'application/json' },
|
|
492
|
+
body: JSON.stringify(patch),
|
|
493
|
+
})
|
|
494
|
+
.then((response) => response.json())
|
|
495
|
+
.then((data) => {
|
|
496
|
+
const v = unwrapValue(data)
|
|
497
|
+
setState((current) => ({
|
|
498
|
+
...current,
|
|
499
|
+
saving: false,
|
|
500
|
+
status: data && data.ok === true ? successText : ((data && data.error) || localeText('saveFail', 'Save failed')),
|
|
501
|
+
deleteMode: v?.deleteMode ?? current.deleteMode,
|
|
502
|
+
deleteDelay: Number.isFinite(Number(v?.deleteDelay)) ? Number(v.deleteDelay) : current.deleteDelay,
|
|
503
|
+
confirmBeforeDelete: v?.confirmBeforeDelete ?? current.confirmBeforeDelete,
|
|
504
|
+
}))
|
|
505
|
+
})
|
|
506
|
+
.catch(() => setState((current) => ({ ...current, saving: false, status: localeText('saveFail', 'Save failed') })))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const reset = () => updateAnyReset()
|
|
510
|
+
|
|
511
|
+
const updateAnyReset = () => {
|
|
512
|
+
setState((current) => ({ ...current, saving: true, status: localeText('saving', 'Saving…') }))
|
|
513
|
+
fetch('/temp-workspace/api/configReset', {
|
|
514
|
+
method: 'POST',
|
|
515
|
+
headers: { 'content-type': 'application/json' },
|
|
516
|
+
body: '{}',
|
|
517
|
+
})
|
|
518
|
+
.then((response) => response.json())
|
|
519
|
+
.then((data) => {
|
|
520
|
+
const v = unwrapValue(data)
|
|
521
|
+
setState((current) => ({
|
|
522
|
+
...current,
|
|
523
|
+
saving: false,
|
|
524
|
+
status: data && data.ok === true ? localeText('restored', 'Defaults restored') : ((data && data.error) || localeText('saveFail', 'Save failed')),
|
|
525
|
+
deleteMode: v?.deleteMode ?? 'immediate',
|
|
526
|
+
deleteDelay: Number.isFinite(Number(v?.deleteDelay)) ? Number(v.deleteDelay) : 3600,
|
|
527
|
+
confirmBeforeDelete: v?.confirmBeforeDelete ?? true,
|
|
528
|
+
}))
|
|
529
|
+
})
|
|
530
|
+
.catch(() => setState((current) => ({ ...current, saving: false, status: localeText('saveFail', 'Save failed') })))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return react.createElement('div', { className: cardCss },
|
|
534
|
+
react.createElement('button', { type: 'button', className: headCss, onClick: () => setOpen((value) => !value) },
|
|
535
|
+
react.createElement('span', { className: headTextCss },
|
|
536
|
+
react.createElement('span', { className: nameCss }, localeText('name', 'Temporary Workspace')),
|
|
537
|
+
react.createElement('span', { className: descCss }, localeText('desc', 'When and whether temp workspaces are deleted after a restart'))),
|
|
538
|
+
react.createElement('span', { className: chevronCss + (open ? ' ' + chevronOpenCss : '') }, '▾')),
|
|
539
|
+
open ? react.createElement('div', { className: bodyCss },
|
|
540
|
+
react.createElement('fieldset', { className: fieldsetCss },
|
|
541
|
+
react.createElement('legend', { className: labelCss }, localeText('deleteModeLabel', 'Delete after restart')),
|
|
542
|
+
react.createElement('label', { className: radioCss },
|
|
543
|
+
react.createElement('input', { type: 'radio', name: 'tw-delete-mode', value: 'immediate', checked: state.deleteMode === 'immediate', disabled: busy, onChange: () => setState((current) => ({ ...current, deleteMode: 'immediate', status: '' })) }),
|
|
544
|
+
react.createElement('span', null, localeText('modeImmediate', 'Delete immediately'))),
|
|
545
|
+
react.createElement('label', { className: radioCss },
|
|
546
|
+
react.createElement('input', { type: 'radio', name: 'tw-delete-mode', value: 'delayed', checked: state.deleteMode === 'delayed', disabled: busy, onChange: () => setState((current) => ({ ...current, deleteMode: 'delayed', status: '' })) }),
|
|
547
|
+
react.createElement('span', null, localeText('modeDelayed', 'Delete after a delay'))),
|
|
548
|
+
state.deleteMode === 'delayed'
|
|
549
|
+
? react.createElement('label', { className: delayCss },
|
|
550
|
+
react.createElement('span', null, localeText('delayLabel', 'Delay (seconds)')),
|
|
551
|
+
react.createElement('input', {
|
|
552
|
+
type: 'number',
|
|
553
|
+
min: 0,
|
|
554
|
+
max: SETTINGS_LIMIT,
|
|
555
|
+
value: delayText,
|
|
556
|
+
disabled: busy,
|
|
557
|
+
onChange: (event) => setState((current) => ({ ...current, deleteDelay: Number(event.target.value) || 0, status: '' })),
|
|
558
|
+
}))
|
|
559
|
+
: null),
|
|
560
|
+
react.createElement('label', { className: toggleCss },
|
|
561
|
+
react.createElement('input', { type: 'checkbox', checked: state.confirmBeforeDelete, disabled: busy, onChange: (event) => setState((current) => ({ ...current, confirmBeforeDelete: event.target.checked, status: '' })) }),
|
|
562
|
+
react.createElement('span', null, localeText('confirmLabel', 'Confirm before deleting'))),
|
|
563
|
+
react.createElement('div', { className: helpCss }, localeText('confirmHelp', 'When on, a dialog asks before a held temp workspace is removed on the next restart.')),
|
|
564
|
+
react.createElement('div', { className: actionsCss },
|
|
565
|
+
react.createElement('button', { type: 'button', className: btnCss, disabled: busy || !valid, onClick: () => update({ deleteMode: state.deleteMode, deleteDelay: state.deleteDelay, confirmBeforeDelete: state.confirmBeforeDelete }, localeText('saved', 'Saved')) }, localeText('save', 'Save')),
|
|
566
|
+
react.createElement('button', { type: 'button', className: btnCss, disabled: busy, onClick: reset }, localeText('reset', 'Restore defaults'))),
|
|
567
|
+
state.status ? react.createElement('div', { className: statusCss }, state.status) : null)
|
|
568
|
+
: null)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (slots) {
|
|
572
|
+
ctx.effect(() => slots.inject('settings.plugin.item', () => slots.register({
|
|
573
|
+
name: 'settings.plugin.item',
|
|
574
|
+
key: SETTINGS_NS,
|
|
575
|
+
id: 'dsh-temp-workspace-settings',
|
|
576
|
+
label: localeText('name', 'Temporary Workspace'),
|
|
577
|
+
}, SettingsCard)), 'dsh-temp-workspace: settings card')
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── cleanup on plugin unload ────────────────────────────────────────────
|
|
581
|
+
return () => {
|
|
582
|
+
observer.disconnect()
|
|
583
|
+
if (scheduled !== null) cancelAnimationFrame(scheduled)
|
|
584
|
+
for (const dispose of disposers) { try { dispose() } catch { /* noop */ } }
|
|
585
|
+
const node = document.querySelector('.' + BUTTON_CLASS)
|
|
586
|
+
if (node !== null) node.remove()
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── lightweight inline-styled dialog (no dependency on primitives CSS) ──────
|
|
591
|
+
// Renders a fixed, centered overlay + dialog box through its own react-dom root
|
|
592
|
+
// so it shows regardless of the app's CSS-module scope. Returns `{ close }`.
|
|
593
|
+
function mountDialog(options, settings) {
|
|
594
|
+
const host = document.createElement('div')
|
|
595
|
+
host.style.position = 'fixed'
|
|
596
|
+
host.style.inset = '0'
|
|
597
|
+
host.style.zIndex = '2147483000'
|
|
598
|
+
host.style.display = 'flex'
|
|
599
|
+
host.style.alignItems = 'center'
|
|
600
|
+
host.style.justifyContent = 'center'
|
|
601
|
+
host.style.padding = '24px'
|
|
602
|
+
host.style.fontFamily = 'var(--dsw-font-family, sans-serif)'
|
|
603
|
+
document.body.appendChild(host)
|
|
604
|
+
|
|
605
|
+
const root = reactDOMClient.createRoot(host)
|
|
606
|
+
const dismissible = options.dismissible !== false
|
|
607
|
+
let closed = false
|
|
608
|
+
const close = () => {
|
|
609
|
+
if (closed) return
|
|
610
|
+
closed = true
|
|
611
|
+
// Remove the Escape listener once closed.
|
|
612
|
+
if (onKey) window.removeEventListener('keydown', onKey)
|
|
613
|
+
root.unmount()
|
|
614
|
+
host.remove()
|
|
615
|
+
if (options.onClose) { try { options.onClose() } catch { /* noop */ } }
|
|
616
|
+
}
|
|
617
|
+
// Non-dismissible dialogs only close via an explicit button (onClick). Mask
|
|
618
|
+
// click and Escape are ignored so the user must act.
|
|
619
|
+
const onKey = (e) => { if (e.key === 'Escape' && dismissible) close() }
|
|
620
|
+
window.addEventListener('keydown', onKey)
|
|
621
|
+
|
|
622
|
+
const maskStyle = {
|
|
623
|
+
position: 'absolute', inset: '0',
|
|
624
|
+
background: 'rgba(0,0,0,0.5)',
|
|
625
|
+
backdropFilter: 'blur(2px)',
|
|
626
|
+
}
|
|
627
|
+
const panelStyle = {
|
|
628
|
+
position: 'relative',
|
|
629
|
+
maxWidth: '440px',
|
|
630
|
+
width: '100%',
|
|
631
|
+
maxHeight: '80vh',
|
|
632
|
+
overflow: 'auto',
|
|
633
|
+
background: 'var(--dsw-alias-bg-layer-3, #1f1f1f)',
|
|
634
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))',
|
|
635
|
+
borderRadius: '14px',
|
|
636
|
+
boxShadow: '0 16px 48px rgba(0,0,0,0.4)',
|
|
637
|
+
padding: '18px 20px',
|
|
638
|
+
color: 'var(--dsw-alias-label-primary, #eee)',
|
|
639
|
+
}
|
|
640
|
+
const btnBase = {
|
|
641
|
+
height: '32px',
|
|
642
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))',
|
|
643
|
+
borderRadius: '8px',
|
|
644
|
+
background: 'transparent',
|
|
645
|
+
color: 'var(--dsw-alias-label-secondary, #ccc)',
|
|
646
|
+
fontSize: '13px',
|
|
647
|
+
padding: '0 14px',
|
|
648
|
+
cursor: 'pointer',
|
|
649
|
+
}
|
|
650
|
+
const primaryBtn = { ...btnBase, background: 'var(--dsw-alias-state-business-primary, #3d9be9)', color: '#fff', border: 'none' }
|
|
651
|
+
|
|
652
|
+
root.render(
|
|
653
|
+
react.createElement('div', null,
|
|
654
|
+
react.createElement('div', { style: maskStyle, onClick: (ev) => { if (ev.target === ev.currentTarget && dismissible && options.onMask) options.onMask() } }),
|
|
655
|
+
react.createElement('div', { role: 'dialog', 'aria-modal': 'true', style: panelStyle },
|
|
656
|
+
options.title ? react.createElement('div', { style: { fontSize: 16, fontWeight: 600, marginBottom: 8 } }, options.title) : null,
|
|
657
|
+
options.description ? react.createElement('div', { style: { fontSize: 13, lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary, #aaa)' } }, options.description) : null,
|
|
658
|
+
options.children ? react.createElement('div', { style: { marginTop: 10 } }, options.children) : null,
|
|
659
|
+
react.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16, flexWrap: 'wrap' } },
|
|
660
|
+
(options.buttons || []).map((button, i) =>
|
|
661
|
+
react.createElement('button', {
|
|
662
|
+
key: i,
|
|
663
|
+
type: 'button',
|
|
664
|
+
autoFocus: !!(settings?.autoFocusFirst && i === 0),
|
|
665
|
+
disabled: !!button.disabled,
|
|
666
|
+
style: button.primary ? primaryBtn : { ...btnBase, ...(button.disabled ? { opacity: 0.45, cursor: 'default' } : {}) },
|
|
667
|
+
onClick: () => { if (button.onClick) button.onClick() },
|
|
668
|
+
}, button.label))),
|
|
669
|
+
),
|
|
670
|
+
)
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
return { close }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Unwrap the wire envelope: { ok: true, value: X } or the double-nested
|
|
677
|
+
// { ok: true, value: { ok: true, value: X } } some host builds returned.
|
|
678
|
+
function unwrapValue(data) {
|
|
679
|
+
if (!data || data.ok !== true) return null
|
|
680
|
+
let value = data.value
|
|
681
|
+
if (value && typeof value === 'object' && value.ok === true && 'value' in value) value = value.value
|
|
682
|
+
return value
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── CSS injection (card + modal) ────────────────────────────────────────────
|
|
686
|
+
let styleInjected = false
|
|
687
|
+
function injectStyle() {
|
|
688
|
+
if (styleInjected) return
|
|
689
|
+
styleInjected = true
|
|
690
|
+
const style = document.createElement('style')
|
|
691
|
+
style.dataset.plugin = SETTINGS_NS
|
|
692
|
+
style.textContent = [
|
|
693
|
+
// settings card, matching the framework's dsw-alias design tokens
|
|
694
|
+
'.dsh-temp-workspace-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;overflow:hidden}',
|
|
695
|
+
'.dsh-temp-workspace-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;display:flex;align-items:center;gap:12px;padding:14px 16px}',
|
|
696
|
+
'.dsh-temp-workspace-head-text{display:flex;flex:1;min-width:0;flex-direction:column;gap:4px}',
|
|
697
|
+
'.dsh-temp-workspace-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
698
|
+
'.dsh-temp-workspace-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
|
|
699
|
+
'.dsh-temp-workspace-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
|
|
700
|
+
'.dsh-temp-workspace-chevron-open{transform:rotate(180deg)}',
|
|
701
|
+
'.dsh-temp-workspace-body{border-top:1px solid var(--dsw-alias-border-l2);padding:14px 16px;display:flex;flex-direction:column;gap:14px}',
|
|
702
|
+
'.dsh-temp-workspace-fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:8px}',
|
|
703
|
+
'.dsh-temp-workspace-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}',
|
|
704
|
+
'.dsh-temp-workspace-radio{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}',
|
|
705
|
+
'.dsh-temp-workspace-delay{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px}',
|
|
706
|
+
'.dsh-temp-workspace-delay input{width:110px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;padding:6px 10px}',
|
|
707
|
+
'.dsh-temp-workspace-toggle{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}',
|
|
708
|
+
'.dsh-temp-workspace-help{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5}',
|
|
709
|
+
'.dsh-temp-workspace-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
710
|
+
'.dsh-temp-workspace-btn{height:30px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;padding:0 12px;cursor:pointer}',
|
|
711
|
+
'.dsh-temp-workspace-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
712
|
+
'.dsh-temp-workspace-btn:disabled{opacity:.45;cursor:default}',
|
|
713
|
+
'.dsh-temp-workspace-status{color:var(--dsw-alias-label-secondary);font-size:11px}',
|
|
714
|
+
].join('')
|
|
715
|
+
document.head.appendChild(style)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── locale helper (zh/en dictionaries; follows the active locale) ───────────
|
|
719
|
+
// The active locale comes from ctx.locale (getLocale().active); kept in a
|
|
720
|
+
// module-level var that a locale subscription refreshes, so every UI text
|
|
721
|
+
// rendered through localeText follows the user's language. English is the
|
|
722
|
+
// fallback when the active-locale key is missing.
|
|
723
|
+
let activeLocale = 'zh' // 'zh' | 'en'; synced from ctx.locale
|
|
724
|
+
|
|
725
|
+
function activeLang(localeRef) {
|
|
726
|
+
try {
|
|
727
|
+
const id = (localeRef !== undefined ? localeRef.getLocale().active : activeLocale)
|
|
728
|
+
return id === 'en' ? 'en' : 'zh'
|
|
729
|
+
} catch { return 'zh' }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function localeText(key, fallbackEn, vars, localeRef) {
|
|
733
|
+
const lang = activeLang(localeRef)
|
|
734
|
+
const dict = lang === 'en' ? enLocale : zhLocale
|
|
735
|
+
// Fall back to English (the dictionary consulted after the active locale
|
|
736
|
+
// misses a key), then to the caller's fallback.
|
|
737
|
+
let text = dict[key] || enLocale[key] || fallbackEn
|
|
738
|
+
if (vars) for (const [name, value] of Object.entries(vars)) {
|
|
739
|
+
text = text.replace(new RegExp('\\{' + name + '\\}', 'g'), String(value))
|
|
740
|
+
}
|
|
741
|
+
return text
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const zhLocale = {
|
|
745
|
+
name: '临时工作区',
|
|
746
|
+
desc: '重启后何时、以及是否先确认再删除临时工作区',
|
|
747
|
+
deleteModeLabel: '重启后何时删除',
|
|
748
|
+
modeImmediate: '立即删除',
|
|
749
|
+
modeDelayed: '延迟一段时间后删除',
|
|
750
|
+
delayLabel: '延迟(秒)',
|
|
751
|
+
confirmLabel: '删除前确认',
|
|
752
|
+
confirmHelp: '开启后,下次重启会先弹窗询问,确认后才删除临时工作区。',
|
|
753
|
+
save: '保存',
|
|
754
|
+
saved: '已保存',
|
|
755
|
+
reset: '恢复默认',
|
|
756
|
+
restored: '已恢复默认',
|
|
757
|
+
saving: '保存中…',
|
|
758
|
+
saveFail: '保存失败',
|
|
759
|
+
loadFail: '读取失败',
|
|
760
|
+
confirmTitle: '临时工作区待处理',
|
|
761
|
+
confirmDesc: '有 {n} 个临时工作区:若删除,其中的对话也会一并删除。',
|
|
762
|
+
delete: '删除',
|
|
763
|
+
keep: '保留',
|
|
764
|
+
tmpKeep: '临时保留',
|
|
765
|
+
close: '关闭',
|
|
766
|
+
movedTitle: '工作区已移动',
|
|
767
|
+
movedDesc: '这些工作区已移出临时区域并转为永久工作区。为了让迁移的对话在新工作区下显示,请立即重启 Harness。',
|
|
768
|
+
restartNow: '立即重启',
|
|
769
|
+
iconTitle: '临时工作区',
|
|
770
|
+
keepTitle: '永久保留(移出临时区)',
|
|
771
|
+
keepAria: '永久保留',
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const enLocale = {
|
|
775
|
+
name: 'Temporary Workspace',
|
|
776
|
+
desc: 'When and whether temp workspaces are deleted after a restart',
|
|
777
|
+
deleteModeLabel: 'Delete after a restart',
|
|
778
|
+
modeImmediate: 'Delete immediately',
|
|
779
|
+
modeDelayed: 'Delete after a delay',
|
|
780
|
+
delayLabel: 'Delay (seconds)',
|
|
781
|
+
confirmLabel: 'Confirm before deleting',
|
|
782
|
+
confirmHelp: 'When on, a dialog asks before a held temp workspace is removed on the next restart.',
|
|
783
|
+
save: 'Save',
|
|
784
|
+
saved: 'Saved',
|
|
785
|
+
reset: 'Restore defaults',
|
|
786
|
+
restored: 'Defaults restored',
|
|
787
|
+
saving: 'Saving…',
|
|
788
|
+
saveFail: 'Save failed',
|
|
789
|
+
loadFail: 'Load failed',
|
|
790
|
+
confirmTitle: 'Temporary workspaces pending',
|
|
791
|
+
confirmDesc: '{n} temporary workspace(s) will be deleted together with their conversations unless you keep them.',
|
|
792
|
+
delete: 'Delete',
|
|
793
|
+
keep: 'Keep',
|
|
794
|
+
tmpKeep: 'Keep temporarily',
|
|
795
|
+
close: 'Close',
|
|
796
|
+
movedTitle: 'Workspace moved',
|
|
797
|
+
movedDesc: 'The workspace(s) were moved out of the temporary area and are now permanent. For the migrated conversations to appear under the new workspace, restart the Harness now.',
|
|
798
|
+
restartNow: 'Restart now',
|
|
799
|
+
iconTitle: 'Temporary workspace',
|
|
800
|
+
keepTitle: 'Keep permanently (move out of temp)',
|
|
801
|
+
keepAria: 'Keep permanently',
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── inline CSS (class strings for the card, keyed off the sidebars' css vars) ──
|
|
805
|
+
const cardCss = 'dsh-temp-workspace-card'
|
|
806
|
+
const headCss = 'dsh-temp-workspace-head'
|
|
807
|
+
const headTextCss = 'dsh-temp-workspace-head-text'
|
|
808
|
+
const nameCss = 'dsh-temp-workspace-name'
|
|
809
|
+
const descCss = 'dsh-temp-workspace-desc'
|
|
810
|
+
const chevronCss = 'dsh-temp-workspace-chevron'
|
|
811
|
+
const chevronOpenCss = 'dsh-temp-workspace-chevron-open'
|
|
812
|
+
const bodyCss = 'dsh-temp-workspace-body'
|
|
813
|
+
const fieldsetCss = 'dsh-temp-workspace-fieldset'
|
|
814
|
+
const labelCss = 'dsh-temp-workspace-label'
|
|
815
|
+
const radioCss = 'dsh-temp-workspace-radio'
|
|
816
|
+
const delayCss = 'dsh-temp-workspace-delay'
|
|
817
|
+
const toggleCss = 'dsh-temp-workspace-toggle'
|
|
818
|
+
const helpCss = 'dsh-temp-workspace-help'
|
|
819
|
+
const actionsCss = 'dsh-temp-workspace-actions'
|
|
820
|
+
const btnCss = 'dsh-temp-workspace-btn'
|
|
821
|
+
const statusCss = 'dsh-temp-workspace-status'
|
|
822
|
+
|
|
823
|
+
module.exports = { inject, apply }
|
|
824
|
+
|
|
825
|
+
return module.exports;
|
|
826
|
+
},
|
|
827
|
+
})
|