dsh-ris-yds 0.0.0-stage → 1.0.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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Watch ris-ide's program config and keep our launcher row in it.
3
+ *
4
+ * ris-ide rewrites `.dsh-ris-ide/config.json` when the panel is initialised or the
5
+ * user clicks "generate/update config". That rewrite is derived from discovery, so
6
+ * the yds launcher row — which ris-ide cannot discover — disappears, and the names
7
+ * of the rows it does generate follow whatever discovery thinks they are. Our row
8
+ * has to be re-injected afterwards.
9
+ *
10
+ * The re-injection lives here, in the yds plugin, as an add-on: ris-ide is never
11
+ * modified and never learns that we exist. We only watch the file it owns and
12
+ * rewrite it *after* it changed, keeping every foreign row byte-for-byte.
13
+ */
14
+
15
+ import { existsSync, readFileSync, statSync, watch } from 'node:fs'
16
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join, resolve } from 'node:path'
19
+
20
+ /** Where the list of watched workspaces lives (machine level, survives restarts). */
21
+ export const WATCH_STATE_FILENAME = '.dsh-ris-yds/ide-watch.json'
22
+
23
+ /** Debounce after a config write before we reconcile (ris-ide writes once). */
24
+ const DEBOUNCE_MS = 400
25
+
26
+ /** Polling fallback — covers editors/FS where `watch` misses events (network mounts). */
27
+ const POLL_MS = 2000
28
+
29
+ /** @returns {string} machine-level state file. */
30
+ export function watchStatePath(home = homedir()) {
31
+ return join(home, WATCH_STATE_FILENAME)
32
+ }
33
+
34
+ /**
35
+ * Register a workspace so the running plugin keeps re-injecting our launcher there.
36
+ * @param workspace - absolute workspace root.
37
+ * @returns {{ok: boolean, workspaces: string[]}} the updated list.
38
+ */
39
+ export function registerWorkspace(workspace, home = homedir()) {
40
+ const path = watchStatePath(home)
41
+ const current = listWatched(home)
42
+ const target = resolve(workspace)
43
+ const next = current.includes(target) ? current : [...current, target]
44
+ try {
45
+ mkdirSync(join(path, '..'), { recursive: true })
46
+ writeFileSync(path, `${JSON.stringify({ workspaces: next }, null, 2)}\n`, { mode: 0o600 })
47
+ return { ok: true, workspaces: next }
48
+ } catch (error) {
49
+ return { ok: false, workspaces: current, error: String(error) }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Stop watching a workspace (used by `yds ide remove`).
55
+ * @param workspace - absolute workspace root.
56
+ * @returns {{ok: boolean, workspaces: string[]}} the updated list.
57
+ */
58
+ export function unregisterWorkspace(workspace, home = homedir()) {
59
+ const path = watchStatePath(home)
60
+ const target = resolve(workspace)
61
+ const next = listWatched(home).filter((item) => item !== target)
62
+ try {
63
+ if (next.length === 0) rmSync(path, { force: true })
64
+ else writeFileSync(path, `${JSON.stringify({ workspaces: next }, null, 2)}\n`, { mode: 0o600 })
65
+ return { ok: true, workspaces: next }
66
+ } catch (error) {
67
+ return { ok: false, workspaces: next, error: String(error) }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @returns {string[]} workspaces the plugin should keep reconciled.
73
+ */
74
+ export function listWatched(home = homedir()) {
75
+ try {
76
+ const parsed = JSON.parse(readFileSync(watchStatePath(home), 'utf8'))
77
+ const list = Array.isArray(parsed?.workspaces) ? parsed.workspaces : []
78
+ return list.filter((item) => typeof item === 'string' && item !== '').map((item) => resolve(item))
79
+ } catch {
80
+ return []
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Reconcile one workspace's config if (and only if) we already own a row in it.
86
+ *
87
+ * Two refusals keep the add-on from fighting the user or ris-ide:
88
+ * - a workspace where our row was deliberately removed (`yds ide remove`) is left
89
+ * alone — {@link syncWorkspace} only acts while our row is present or absent
90
+ * *because ris-ide rewrote the file*;
91
+ * - a config that does not exist is not created (ris-ide owns the file's birth).
92
+ * @param workspace - absolute workspace root.
93
+ * @param deps - `{readConfig, writeConfig, hasOurRow, repair}` injected to avoid cycles.
94
+ * @returns {Promise<{changed: boolean, reason: string}>} the outcome.
95
+ */
96
+ export async function reconcileOnce(workspace, deps, options = {}) {
97
+ const configPath = join(workspace, '.dsh-ris-ide', 'config.json')
98
+ if (existsSync(configPath) !== true) return { changed: false, reason: 'no-config' }
99
+ if (deps === undefined || typeof deps.repair !== 'function') return { changed: false, reason: 'no-repair' }
100
+ let before
101
+ try {
102
+ before = readFileSync(configPath, 'utf8')
103
+ } catch {
104
+ return { changed: false, reason: 'unreadable' }
105
+ }
106
+ const restored = await deps.repair(workspace)
107
+ if (restored?.changed !== true) return { changed: false, reason: restored?.reason ?? 'unchanged' }
108
+ let after
109
+ try {
110
+ after = readFileSync(configPath, 'utf8')
111
+ } catch {
112
+ after = ''
113
+ }
114
+ if (after === before) return { changed: false, reason: 'no-op' }
115
+ if (options.log !== undefined) options.log(`[yds] 已把启动器条目重新注入 ${configPath}`)
116
+ return { changed: true, reason: restored.reason ?? 'injected' }
117
+ }
118
+
119
+ /**
120
+ * Keep one workspace reconciled: `fs.watch` on `.dsh-ris-ide` plus a polling
121
+ * fallback, both funnelled through a debounce. Returns a disposer.
122
+ * @param workspace - absolute workspace root.
123
+ * @param deps - see {@link reconcileOnce}.
124
+ * @param options - `{log}` optional logger.
125
+ * @returns {() => void} disposer.
126
+ */
127
+ export function watchWorkspace(workspace, deps, options = {}) {
128
+ const root = resolve(workspace)
129
+ const dir = join(root, '.dsh-ris-ide')
130
+ const configPath = join(dir, 'config.json')
131
+ let timer
132
+ let lastMtime = 0
133
+ let stopped = false
134
+ const run = () => {
135
+ if (stopped) return
136
+ void reconcileOnce(root, deps, options).catch(() => {})
137
+ }
138
+ const schedule = () => {
139
+ if (stopped) return
140
+ clearTimeout(timer)
141
+ timer = setTimeout(run, DEBOUNCE_MS)
142
+ timer.unref?.()
143
+ }
144
+ let watcher = null
145
+ try {
146
+ watcher = watch(dir, { persistent: false }, (event, filename) => {
147
+ if (typeof filename === 'string' && filename !== 'config.json') return
148
+ if (event !== undefined) schedule()
149
+ })
150
+ watcher.on?.('error', () => {})
151
+ } catch {
152
+ watcher = null // the directory may not exist yet; the poller below covers us
153
+ }
154
+ const poll = setInterval(() => {
155
+ if (stopped) return
156
+ try {
157
+ const { mtimeMs } = statSync(configPath)
158
+ if (mtimeMs !== lastMtime) {
159
+ lastMtime = mtimeMs
160
+ schedule()
161
+ }
162
+ } catch {
163
+ // no config yet — nothing to reconcile
164
+ }
165
+ }, POLL_MS)
166
+ poll.unref?.()
167
+ return () => {
168
+ stopped = true
169
+ clearTimeout(timer)
170
+ clearInterval(poll)
171
+ try {
172
+ watcher?.close?.()
173
+ } catch {
174
+ // ignore
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Start watchers for every registered workspace.
181
+ * @param deps - see {@link reconcileOnce}.
182
+ * @param options - `{log, home}` optional.
183
+ * @returns {() => void} disposer stopping all watchers.
184
+ */
185
+ export function watchAll(deps, options = {}) {
186
+ const disposers = listWatched(options.home).map((workspace) => watchWorkspace(workspace, deps, options))
187
+ return () => disposers.forEach((dispose) => { try { dispose() } catch { /* ignore */ } })
188
+ }