@raidou/pi-notify 0.5.1 → 0.5.2
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.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme, ThemeColor } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
import { visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
5
|
import type { SessionRecord } from '../state-store.js'
|
|
6
6
|
import { Dashboard } from './dashboard.js'
|
|
@@ -27,16 +27,21 @@ function makeSession(overrides: Partial<SessionRecord>): SessionRecord {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function makeDashboard(
|
|
30
|
+
function makeDashboard(
|
|
31
|
+
sessions: SessionRecord[],
|
|
32
|
+
overrides: Partial<{
|
|
33
|
+
onRefresh: () => Promise<SessionRecord[]>
|
|
34
|
+
}> = {},
|
|
35
|
+
) {
|
|
36
|
+
const onRefresh = overrides.onRefresh ?? (async () => sessions)
|
|
31
37
|
return new Dashboard({
|
|
32
38
|
tui: { requestRender: () => {} },
|
|
33
39
|
theme,
|
|
34
40
|
initialSessions: sessions,
|
|
35
|
-
onRefresh
|
|
41
|
+
onRefresh,
|
|
36
42
|
onClose: () => {},
|
|
37
43
|
})
|
|
38
44
|
}
|
|
39
|
-
|
|
40
45
|
describe('Dashboard render clipping', () => {
|
|
41
46
|
const longNameSession = makeSession({
|
|
42
47
|
projectName: 'a-very-long-project-name-that-exceeds-any-narrow-width',
|
|
@@ -65,12 +70,12 @@ describe('Dashboard render clipping', () => {
|
|
|
65
70
|
const visible = lines.map(stripAnsi)
|
|
66
71
|
const header = visible.find((l) => l.includes('STATE'))
|
|
67
72
|
expect(header).toBeDefined()
|
|
68
|
-
const footer = visible.find((l) => l.startsWith('o ids'))
|
|
73
|
+
const footer = visible.find((l) => l.startsWith('[o] show/hide ids'))
|
|
69
74
|
expect(footer).toBeDefined()
|
|
70
75
|
if (!header || !footer) throw new Error('unreachable')
|
|
71
76
|
expect(header.startsWith('STATE')).toBe(true)
|
|
72
77
|
expect(header.endsWith('…')).toBe(true)
|
|
73
|
-
expect(footer.startsWith('o ids
|
|
78
|
+
expect(footer.startsWith('[o] show/hide ids ·…')).toBe(true)
|
|
74
79
|
for (const line of visible) {
|
|
75
80
|
expect(visibleWidth(line)).toBeLessThanOrEqual(20)
|
|
76
81
|
}
|
|
@@ -116,3 +121,21 @@ describe('hidden column toggle', () => {
|
|
|
116
121
|
}
|
|
117
122
|
})
|
|
118
123
|
})
|
|
124
|
+
|
|
125
|
+
describe('auto-refresh', () => {
|
|
126
|
+
it('manual r triggers refresh', () => {
|
|
127
|
+
const onRefresh = vi.fn(async () => [makeSession({})])
|
|
128
|
+
const dashboard = makeDashboard([makeSession({})], { onRefresh })
|
|
129
|
+
dashboard.handleInput('r')
|
|
130
|
+
expect(onRefresh).toHaveBeenCalledTimes(1)
|
|
131
|
+
dashboard.dispose()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('dispose stops further refreshes', () => {
|
|
135
|
+
const onRefresh = vi.fn(async () => [makeSession({})])
|
|
136
|
+
const dashboard = makeDashboard([makeSession({})], { onRefresh })
|
|
137
|
+
dashboard.dispose()
|
|
138
|
+
dashboard.handleInput('r')
|
|
139
|
+
expect(onRefresh).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -3,6 +3,7 @@ import type { Component } from '@earendil-works/pi-tui'
|
|
|
3
3
|
import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui'
|
|
4
4
|
|
|
5
5
|
import type { SessionRecord } from '../state-store.js'
|
|
6
|
+
import { watchStore } from '../watch-store.js'
|
|
6
7
|
import {
|
|
7
8
|
COLUMN_SEPARATOR,
|
|
8
9
|
resolveColumns,
|
|
@@ -29,7 +30,7 @@ export class Dashboard implements Component {
|
|
|
29
30
|
private cachedLines: string[] = []
|
|
30
31
|
private disposed = false
|
|
31
32
|
private showHidden = false
|
|
32
|
-
private
|
|
33
|
+
private readonly stopWatching: () => void
|
|
33
34
|
|
|
34
35
|
constructor({
|
|
35
36
|
tui,
|
|
@@ -46,10 +47,9 @@ export class Dashboard implements Component {
|
|
|
46
47
|
this.onDispose = onDispose
|
|
47
48
|
this.sessions = [...initialSessions]
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}, 10000)
|
|
50
|
+
this.stopWatching = watchStore(() => {
|
|
51
|
+
this.refresh()
|
|
52
|
+
})
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
private forceRender(): void {
|
|
@@ -58,6 +58,7 @@ export class Dashboard implements Component {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
private refresh(): void {
|
|
61
|
+
if (this.disposed) return
|
|
61
62
|
this.onRefresh()
|
|
62
63
|
.then((newSessions) => {
|
|
63
64
|
this.sessions = [...newSessions]
|
|
@@ -87,12 +88,30 @@ export class Dashboard implements Component {
|
|
|
87
88
|
.join(COLUMN_SEPARATOR),
|
|
88
89
|
),
|
|
89
90
|
'',
|
|
90
|
-
this.
|
|
91
|
+
this.footerLine(width, [
|
|
92
|
+
['o', 'show/hide ids'],
|
|
93
|
+
['r', 'refresh'],
|
|
94
|
+
['q/esc', 'close'],
|
|
95
|
+
]),
|
|
91
96
|
]
|
|
92
97
|
this.cachedLines = rows.map((line) => truncateToWidth(line, width, '…'))
|
|
93
98
|
return this.cachedLines
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
private footerLine(width: number, keys: [string, string][]): string {
|
|
102
|
+
const sep = this.theme.fg('dim', ' · ')
|
|
103
|
+
return truncateToWidth(
|
|
104
|
+
keys
|
|
105
|
+
.map(
|
|
106
|
+
([key, desc]) =>
|
|
107
|
+
`${this.theme.fg('syntaxKeyword', `[${key}]`)} ${this.theme.fg('success', desc)}`,
|
|
108
|
+
)
|
|
109
|
+
.join(sep),
|
|
110
|
+
width,
|
|
111
|
+
'…',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
handleInput(data: string): void {
|
|
97
116
|
if (matchesKey(data, 'r')) {
|
|
98
117
|
this.refresh()
|
|
@@ -116,7 +135,7 @@ export class Dashboard implements Component {
|
|
|
116
135
|
|
|
117
136
|
dispose(): void {
|
|
118
137
|
this.disposed = true
|
|
119
|
-
|
|
138
|
+
this.stopWatching()
|
|
120
139
|
this.onDispose?.()
|
|
121
140
|
}
|
|
122
141
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { afterAll, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
vi.mock('./consts.js', async () => {
|
|
7
|
+
const { mkdtempSync } = await import('node:fs')
|
|
8
|
+
const path = await import('node:path')
|
|
9
|
+
const { tmpdir } = await import('node:os')
|
|
10
|
+
|
|
11
|
+
const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-watch-test-'))
|
|
12
|
+
return {
|
|
13
|
+
STATE_FILE: path.join(stateDir, 'state.json'),
|
|
14
|
+
STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
rmSync(dirname(STATE_FILE), { recursive: true, force: true })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
import { STATE_FILE } from './consts.js'
|
|
23
|
+
import { watchStore } from './watch-store.js'
|
|
24
|
+
|
|
25
|
+
const DEBOUNCE_MS = 25
|
|
26
|
+
|
|
27
|
+
function waitFor(cb: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const start = Date.now()
|
|
30
|
+
const check = (): void => {
|
|
31
|
+
if (cb()) {
|
|
32
|
+
resolve()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (Date.now() - start > timeoutMs) {
|
|
36
|
+
reject(new Error('waitFor timed out'))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
setTimeout(check, 10)
|
|
40
|
+
}
|
|
41
|
+
check()
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeState(data: string): void {
|
|
46
|
+
const tmp = `${STATE_FILE}.tmp`
|
|
47
|
+
writeFileSync(tmp, data, 'utf8')
|
|
48
|
+
renameSync(tmp, STATE_FILE)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('watchStore', () => {
|
|
52
|
+
it('fires once for a tmp+rename write', async () => {
|
|
53
|
+
const onChange = vi.fn()
|
|
54
|
+
const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
|
|
55
|
+
try {
|
|
56
|
+
writeState('{"version":2,"sessions":{}}')
|
|
57
|
+
await waitFor(() => onChange.mock.calls.length > 0)
|
|
58
|
+
await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 3))
|
|
59
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
60
|
+
} finally {
|
|
61
|
+
stop()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('collapses a burst of writes into one call', async () => {
|
|
66
|
+
const onChange = vi.fn()
|
|
67
|
+
const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
|
|
68
|
+
try {
|
|
69
|
+
writeState('a')
|
|
70
|
+
writeState('b')
|
|
71
|
+
await new Promise((r) => setTimeout(r, DEBOUNCE_MS / 2))
|
|
72
|
+
writeState('c')
|
|
73
|
+
await waitFor(() => onChange.mock.calls.length > 0)
|
|
74
|
+
await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 3))
|
|
75
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
76
|
+
} finally {
|
|
77
|
+
stop()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('ignores tmp and lock file writes', async () => {
|
|
82
|
+
const onChange = vi.fn()
|
|
83
|
+
const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(`${STATE_FILE}.tmp`, 'tmp', 'utf8')
|
|
86
|
+
writeFileSync(`${STATE_FILE}.lock`, 'lock', 'utf8')
|
|
87
|
+
await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 5))
|
|
88
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
89
|
+
} finally {
|
|
90
|
+
stop()
|
|
91
|
+
rmSync(`${STATE_FILE}.tmp`, { force: true })
|
|
92
|
+
rmSync(`${STATE_FILE}.lock`, { force: true })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('stops events after dispose', async () => {
|
|
97
|
+
const onChange = vi.fn()
|
|
98
|
+
const stop = watchStore(onChange, { debounceMs: DEBOUNCE_MS })
|
|
99
|
+
stop()
|
|
100
|
+
writeState('after-dispose')
|
|
101
|
+
await new Promise((r) => setTimeout(r, DEBOUNCE_MS * 5))
|
|
102
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('is idempotent when disposed twice', () => {
|
|
106
|
+
const stop = watchStore(() => {}, { debounceMs: DEBOUNCE_MS })
|
|
107
|
+
stop()
|
|
108
|
+
expect(() => {
|
|
109
|
+
stop()
|
|
110
|
+
}).not.toThrow()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { basename, dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { STATE_FILE } from './consts.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DEBOUNCE_MS = 150
|
|
7
|
+
|
|
8
|
+
export function watchStore(
|
|
9
|
+
onChange: () => void,
|
|
10
|
+
opts?: { debounceMs?: number },
|
|
11
|
+
): () => void {
|
|
12
|
+
const debounceMs = opts?.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
|
13
|
+
const stateFileName = basename(STATE_FILE)
|
|
14
|
+
|
|
15
|
+
let timer: NodeJS.Timeout | null = null
|
|
16
|
+
let closed = false
|
|
17
|
+
|
|
18
|
+
let watcher: FSWatcher
|
|
19
|
+
try {
|
|
20
|
+
watcher = watch(dirname(STATE_FILE), (_event, filename) => {
|
|
21
|
+
if (filename !== null && filename !== stateFileName) return
|
|
22
|
+
if (timer !== null) clearTimeout(timer)
|
|
23
|
+
timer = setTimeout(() => {
|
|
24
|
+
timer = null
|
|
25
|
+
onChange()
|
|
26
|
+
}, debounceMs)
|
|
27
|
+
timer.unref()
|
|
28
|
+
})
|
|
29
|
+
} catch {
|
|
30
|
+
return () => {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
watcher.on('error', () => {
|
|
34
|
+
if (closed) return
|
|
35
|
+
closed = true
|
|
36
|
+
watcher.close()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
if (timer !== null) {
|
|
41
|
+
clearTimeout(timer)
|
|
42
|
+
timer = null
|
|
43
|
+
}
|
|
44
|
+
if (closed) return
|
|
45
|
+
closed = true
|
|
46
|
+
watcher.close()
|
|
47
|
+
}
|
|
48
|
+
}
|