@anionex/dsh-pinned-sessions 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 +44 -0
- package/README.zh.md +44 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +794 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/types/client/dom-bridge.d.ts +54 -0
- package/lib/types/client/index.d.ts +61 -0
- package/lib/types/client/pin-store.d.ts +27 -0
- package/lib/types/client/presentation.d.ts +8 -0
- package/lib/types/index.d.ts +2 -0
- package/package.json +97 -0
- package/scripts/clean.mjs +6 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/dom-bridge.ts +242 -0
- package/src/client/index.tsx +445 -0
- package/src/client/pin-store.ts +116 -0
- package/src/client/presentation.ts +27 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export const PIN_STORAGE_KEY = 'dsh.pinned-sessions.v1'
|
|
2
|
+
const MAX_PINS = 500
|
|
3
|
+
|
|
4
|
+
export interface PinRecord {
|
|
5
|
+
readonly id: string
|
|
6
|
+
readonly pinnedAt: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface PersistedPins {
|
|
10
|
+
readonly version: 1
|
|
11
|
+
readonly pins: readonly PinRecord[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StorageLike {
|
|
15
|
+
getItem(key: string): string | null
|
|
16
|
+
setItem(key: string, value: string): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPinRecord(value: unknown): value is PinRecord {
|
|
20
|
+
if (typeof value !== 'object' || value === null) return false
|
|
21
|
+
const row = value as Record<string, unknown>
|
|
22
|
+
return typeof row.id === 'string'
|
|
23
|
+
&& row.id.length > 0
|
|
24
|
+
&& Number.isFinite(row.pinnedAt)
|
|
25
|
+
&& (row.pinnedAt as number) >= 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Decode, de-duplicate, and normalize one persisted pin snapshot. */
|
|
29
|
+
export function decodePins(raw: string | null): readonly PinRecord[] {
|
|
30
|
+
if (raw === null) return []
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(raw) as Partial<PersistedPins>
|
|
33
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.pins)) return []
|
|
34
|
+
const byId = new Map<string, PinRecord>()
|
|
35
|
+
for (const candidate of parsed.pins) {
|
|
36
|
+
if (!isPinRecord(candidate)) continue
|
|
37
|
+
const previous = byId.get(candidate.id)
|
|
38
|
+
if (previous === undefined || candidate.pinnedAt > previous.pinnedAt) {
|
|
39
|
+
byId.set(candidate.id, { id: candidate.id, pinnedAt: candidate.pinnedAt })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...byId.values()]
|
|
43
|
+
.sort((left, right) => right.pinnedAt - left.pinnedAt || left.id.localeCompare(right.id))
|
|
44
|
+
.slice(0, MAX_PINS)
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Browser-local, versioned pin state with React-compatible subscriptions. */
|
|
51
|
+
export class PinStore {
|
|
52
|
+
private snapshot: readonly PinRecord[]
|
|
53
|
+
private readonly listeners = new Set<() => void>()
|
|
54
|
+
|
|
55
|
+
constructor(private readonly storage: StorageLike | undefined) {
|
|
56
|
+
this.snapshot = this.read()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
readonly getSnapshot = (): readonly PinRecord[] => this.snapshot
|
|
60
|
+
|
|
61
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
62
|
+
this.listeners.add(listener)
|
|
63
|
+
return () => { this.listeners.delete(listener) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
isPinned(id: string): boolean {
|
|
67
|
+
return this.snapshot.some(row => row.id === id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
toggle(id: string, now = Date.now()): void {
|
|
71
|
+
if (id.length === 0) return
|
|
72
|
+
const without = this.snapshot.filter(row => row.id !== id)
|
|
73
|
+
this.publish(this.isPinned(id) ? without : [{ id, pinnedAt: now }, ...without])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
prune(keep: (id: string) => boolean): void {
|
|
77
|
+
const next = this.snapshot.filter(row => keep(row.id))
|
|
78
|
+
if (next.length !== this.snapshot.length) this.publish(next)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
reload(): void {
|
|
82
|
+
const next = this.read()
|
|
83
|
+
if (samePins(this.snapshot, next)) return
|
|
84
|
+
this.snapshot = next
|
|
85
|
+
this.emit()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private read(): readonly PinRecord[] {
|
|
89
|
+
try {
|
|
90
|
+
return decodePins(this.storage?.getItem(PIN_STORAGE_KEY) ?? null)
|
|
91
|
+
} catch {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private publish(next: readonly PinRecord[]): void {
|
|
97
|
+
this.snapshot = [...next]
|
|
98
|
+
.sort((left, right) => right.pinnedAt - left.pinnedAt || left.id.localeCompare(right.id))
|
|
99
|
+
.slice(0, MAX_PINS)
|
|
100
|
+
try {
|
|
101
|
+
this.storage?.setItem(PIN_STORAGE_KEY, JSON.stringify({ version: 1, pins: this.snapshot }))
|
|
102
|
+
} catch {
|
|
103
|
+
// Storage can be unavailable in hardened or quota-constrained browser profiles.
|
|
104
|
+
}
|
|
105
|
+
this.emit()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private emit(): void {
|
|
109
|
+
for (const listener of [...this.listeners]) listener()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function samePins(left: readonly PinRecord[], right: readonly PinRecord[]): boolean {
|
|
114
|
+
return left.length === right.length
|
|
115
|
+
&& left.every((row, index) => row.id === right[index]?.id && row.pinnedAt === right[index]?.pinnedAt)
|
|
116
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface SessionPresentationInput {
|
|
2
|
+
readonly id: string
|
|
3
|
+
readonly title?: string | undefined
|
|
4
|
+
readonly cwd?: string | undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function nonBlank(value: string | undefined): string | undefined {
|
|
8
|
+
const trimmed = value?.trim()
|
|
9
|
+
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Match the native sidebar's useful fallback for sessions without a title. */
|
|
13
|
+
export function sessionDisplayTitle(
|
|
14
|
+
session: SessionPresentationInput,
|
|
15
|
+
workspaceTitle: string | undefined,
|
|
16
|
+
): string {
|
|
17
|
+
const explicit = nonBlank(session.title)
|
|
18
|
+
if (explicit !== undefined) return explicit
|
|
19
|
+
const workspace = nonBlank(workspaceTitle)
|
|
20
|
+
if (workspace !== undefined) return workspace
|
|
21
|
+
const cwd = nonBlank(session.cwd)
|
|
22
|
+
if (cwd !== undefined) {
|
|
23
|
+
const leaf = cwd.replace(/[\\/]+$/u, '').split(/[\\/]/u).at(-1)
|
|
24
|
+
if (leaf !== undefined && leaf.length > 0) return leaf
|
|
25
|
+
}
|
|
26
|
+
return session.id
|
|
27
|
+
}
|
package/src/index.ts
ADDED