@linxin666/dsh-client-ui-task-board 0.1.12 → 0.1.14
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/README.i18n.yaml +6 -0
- package/README.md +92 -174
- package/README.zh.md +132 -0
- package/lib/client.js +177 -108
- package/lib/client.js.map +1 -1
- package/lib/types/client/apply-guard.d.ts +25 -0
- package/lib/types/core/store.d.ts +29 -1
- package/package.json +1 -6
- package/src/client/PluginSettingsCard.tsx +30 -5
- package/src/client/apply-guard.ts +35 -0
- package/src/client/board-mount.tsx +6 -3
- package/src/client/board.module.css +2 -1
- package/src/client/index.ts +11 -0
- package/src/client/settings-card.module.css +146 -116
- package/src/client/sidebar-entry.ts +7 -0
- package/src/core/controller.ts +19 -4
- package/src/core/store.ts +41 -0
|
@@ -87,6 +87,13 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
|
87
87
|
* @returns disposer removing the entry and its observers.
|
|
88
88
|
*/
|
|
89
89
|
export function mountSidebarEntry(controller: BoardController): () => void {
|
|
90
|
+
// DOM-level idempotency: whatever path mounted an entry row before this
|
|
91
|
+
// call (a duplicated apply, an HMR re-injection, a stale module still
|
|
92
|
+
// alive), never mount a second one. The existing row keeps working; a full
|
|
93
|
+
// page reload is the ultimate reset.
|
|
94
|
+
if (typeof document !== 'undefined' && document.querySelector('[data-dsh-taskboard-entry]') !== null) {
|
|
95
|
+
return () => {}
|
|
96
|
+
}
|
|
90
97
|
const entry = createEntry(controller)
|
|
91
98
|
let root: HTMLElement | undefined
|
|
92
99
|
let placed = false
|
package/src/core/controller.ts
CHANGED
|
@@ -94,6 +94,15 @@ export class BoardController {
|
|
|
94
94
|
start(): void {
|
|
95
95
|
this.tasks = this.deps.store.load()
|
|
96
96
|
void this.reconcileRunningTasks()
|
|
97
|
+
// A sibling tab may have edited or deleted the ledger (same origin,
|
|
98
|
+
// storage events). Reload on external change so a task deleted in
|
|
99
|
+
// another tab stops firing here — and is never written back by this
|
|
100
|
+
// tab's stale copy (scheduler roll-forward, execution settlement).
|
|
101
|
+
const unsubscribeExternal = this.deps.store.subscribeExternal?.(() => {
|
|
102
|
+
this.tasks = this.deps.store.load()
|
|
103
|
+
this.notify()
|
|
104
|
+
})
|
|
105
|
+
if (unsubscribeExternal !== undefined) this.disposers.push(unsubscribeExternal)
|
|
97
106
|
this.disposers.push(this.deps.sessions.list.subscribe(() => {
|
|
98
107
|
this.onSessionsChanged()
|
|
99
108
|
}))
|
|
@@ -329,7 +338,7 @@ export class BoardController {
|
|
|
329
338
|
this.reconcileInFlight = true
|
|
330
339
|
try {
|
|
331
340
|
type Settled = Extract<ExecutionEvent, { kind: 'settled' }>
|
|
332
|
-
const events: Array<{
|
|
341
|
+
const events: Array<{ taskId: string; event: Settled }> = []
|
|
333
342
|
for (const task of this.tasks) {
|
|
334
343
|
if (task.status !== 'running') continue
|
|
335
344
|
const execution = task.executions[task.executions.length - 1]
|
|
@@ -337,14 +346,20 @@ export class BoardController {
|
|
|
337
346
|
// boundary); reconciliation exists for background/leftover runs.
|
|
338
347
|
if (execution !== undefined && this.activeExecutionIds.has(execution.id)) continue
|
|
339
348
|
const event = await this.deps.exec.reconcile(task)
|
|
340
|
-
if (event !== undefined && event.kind === 'settled') events.push({ task, event })
|
|
349
|
+
if (event !== undefined && event.kind === 'settled') events.push({ taskId: task.id, event })
|
|
341
350
|
}
|
|
342
351
|
if (events.length === 0) return
|
|
343
352
|
let changed = false
|
|
344
|
-
for (const {
|
|
353
|
+
for (const { taskId, event } of events) {
|
|
354
|
+
// The reconcile call above awaited: a sibling tab may have rewritten
|
|
355
|
+
// the ledger (storage event reload) meanwhile. Re-read the freshest
|
|
356
|
+
// record now so the stale task captured before the await can never
|
|
357
|
+
// overwrite fields the sibling wrote.
|
|
358
|
+
const task = this.tasks.find(candidate => candidate.id === taskId)
|
|
359
|
+
if (task === undefined) continue
|
|
345
360
|
const next = settleExecution(task, event.executionId, event.outcome, this.now(), event.error)
|
|
346
361
|
if (next === task) continue
|
|
347
|
-
this.tasks = this.tasks.map(candidate => candidate.id ===
|
|
362
|
+
this.tasks = this.tasks.map(candidate => candidate.id === taskId ? next : candidate)
|
|
348
363
|
changed = true
|
|
349
364
|
}
|
|
350
365
|
if (changed) this.persistAndNotify()
|
package/src/core/store.ts
CHANGED
|
@@ -25,11 +25,30 @@ export interface TaskStore {
|
|
|
25
25
|
save(tasks: readonly TaskRecord[]): void
|
|
26
26
|
/** Drop the persisted ledger (leaves the in-memory state alone). */
|
|
27
27
|
clear(): void
|
|
28
|
+
/**
|
|
29
|
+
* Subscribe to ledger changes written by ANOTHER tab of the same origin
|
|
30
|
+
* (browser storage events). The board controller reloads the ledger on
|
|
31
|
+
* such a change, so a task deleted in one tab cannot keep firing (or be
|
|
32
|
+
* written back) from the stale in-memory copy of another tab. No-op when
|
|
33
|
+
* the backend has no cross-instance channel (in-memory store).
|
|
34
|
+
*/
|
|
35
|
+
subscribeExternal?(listener: () => void): () => void
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
/** Storage key for the task ledger document. */
|
|
31
39
|
export const DEFAULT_STORAGE_KEY = 'dsh.taskBoard.v1'
|
|
32
40
|
|
|
41
|
+
/** Structural shape of the storage event fired in sibling tabs (DOM-free). */
|
|
42
|
+
export interface StorageChangeEvent {
|
|
43
|
+
key: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The event-target face the store needs for cross-tab notifications. */
|
|
47
|
+
export interface StorageEvents {
|
|
48
|
+
addEventListener(type: 'storage', listener: (event: StorageChangeEvent) => void): void
|
|
49
|
+
removeEventListener(type: 'storage', listener: (event: StorageChangeEvent) => void): void
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
/**
|
|
34
53
|
* Structural row check with the status left unvalidated (see {@link parseLedger}).
|
|
35
54
|
* The `schedule` field is deliberately NOT checked here: a malformed schedule
|
|
@@ -127,10 +146,16 @@ export class LocalStorageTaskStore implements TaskStore {
|
|
|
127
146
|
/**
|
|
128
147
|
* @param key - storage key for the ledger document.
|
|
129
148
|
* @param storage - storage backend (defaults to the global localStorage; tests inject fakes).
|
|
149
|
+
* @param events - storage-event target for cross-tab notifications (defaults
|
|
150
|
+
* to the browser global; undefined in non-browser runtimes, where the
|
|
151
|
+
* subscription becomes a no-op).
|
|
130
152
|
*/
|
|
131
153
|
constructor(
|
|
132
154
|
private readonly key: string = DEFAULT_STORAGE_KEY,
|
|
133
155
|
private readonly storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | undefined = globalThis.localStorage,
|
|
156
|
+
private readonly events: StorageEvents | undefined = typeof (globalThis as { addEventListener?: unknown }).addEventListener === 'function'
|
|
157
|
+
? (globalThis as unknown as StorageEvents)
|
|
158
|
+
: undefined,
|
|
134
159
|
) {}
|
|
135
160
|
|
|
136
161
|
load(): TaskRecord[] {
|
|
@@ -163,6 +188,22 @@ export class LocalStorageTaskStore implements TaskStore {
|
|
|
163
188
|
console.error('[dsh-task-board] task ledger clear failed', error)
|
|
164
189
|
}
|
|
165
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Cross-tab change subscription (see {@link TaskStore.subscribeExternal}).
|
|
194
|
+
* The browser fires the storage event in every OTHER tab of the same origin
|
|
195
|
+
* when one tab writes; a null key means the whole storage was cleared. Both
|
|
196
|
+
* cases reload the ledger here; unrelated keys are ignored.
|
|
197
|
+
*/
|
|
198
|
+
subscribeExternal(listener: () => void): () => void {
|
|
199
|
+
if (this.events === undefined) return () => {}
|
|
200
|
+
const onStorage = (event: StorageChangeEvent): void => {
|
|
201
|
+
if (event.key !== null && event.key !== this.key) return
|
|
202
|
+
listener()
|
|
203
|
+
}
|
|
204
|
+
this.events.addEventListener('storage', onStorage)
|
|
205
|
+
return () => { this.events?.removeEventListener('storage', onStorage) }
|
|
206
|
+
}
|
|
166
207
|
}
|
|
167
208
|
|
|
168
209
|
/** In-memory backend (tests, and a fallback when storage is unavailable). */
|