@luuthuong/devboard 0.0.0-stage → 1.0.1
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/.claude/skills/devboard/SKILL.md +111 -0
- package/bin/devboard.mjs +59 -0
- package/dist/assets/CardDetailView-BZE_MfjM.js +24 -0
- package/dist/assets/DiffPane-2WnoRvZ0.js +22 -0
- package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
- package/dist/assets/KnowledgeView-C4WPFAUM.js +3 -0
- package/dist/assets/ListBoardView-DEE_eK4h.js +3 -0
- package/dist/assets/NewTaskDialog-D8cpc7Bc.js +1 -0
- package/dist/assets/OnboardingWizard-9ZkFPGvH.js +1 -0
- package/dist/assets/ProcessManagerView-DJOr44DY.js +1 -0
- package/dist/assets/ProposalCard-B8jqFtL8.js +1 -0
- package/dist/assets/SearchOverlay-qGxzmM_v.js +1 -0
- package/dist/assets/SettingsView-C3GkCbTz.js +1 -0
- package/dist/assets/SetupProjectView-CSx6ax5r.js +1 -0
- package/dist/assets/Surface-BQKeigoq.js +1 -0
- package/dist/assets/TaskDescriptionEditor-D0_b81zY.js +58 -0
- package/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/dist/assets/Terminal-yq90NOBX.js +80 -0
- package/dist/assets/deepEqual-BiJVGDfM.js +1 -0
- package/dist/assets/index-BtXUncAU.css +1 -0
- package/dist/assets/index-CQnL8JJ5.js +18 -0
- package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
- package/dist/assets/plus-Cecrm8sZ.js +1 -0
- package/dist/assets/react-dom-DjtJUrx3.js +1 -0
- package/dist/assets/x-B6wVesqk.js +1 -0
- package/dist/index.html +22 -0
- package/package.json +81 -4
- package/server-dist/server/assets/remote-shell.sh +239 -0
- package/server-dist/server/board.js +486 -0
- package/server-dist/server/commands.js +390 -0
- package/server-dist/server/context.js +34 -0
- package/server-dist/server/coordination.js +442 -0
- package/server-dist/server/editor.js +101 -0
- package/server-dist/server/events.js +17 -0
- package/server-dist/server/fsBrowse.js +143 -0
- package/server-dist/server/git.js +258 -0
- package/server-dist/server/harness/config.js +90 -0
- package/server-dist/server/harness/entry.js +98 -0
- package/server-dist/server/harness/intents.js +268 -0
- package/server-dist/server/harness/kbIndex.js +291 -0
- package/server-dist/server/harness/kbStore.js +258 -0
- package/server-dist/server/harness/librarian.js +147 -0
- package/server-dist/server/harness/pack.js +182 -0
- package/server-dist/server/harness/proposals.js +113 -0
- package/server-dist/server/harness/repo.js +150 -0
- package/server-dist/server/harness/scrub.js +30 -0
- package/server-dist/server/harness/service.js +683 -0
- package/server-dist/server/harness/template/README.md +38 -0
- package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
- package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
- package/server-dist/server/harness/template/agents/test-writer.md +16 -0
- package/server-dist/server/harness/template/agents.yaml +41 -0
- package/server-dist/server/harness/template/harness.json +11 -0
- package/server-dist/server/harness/template/kb/team/README.md +4 -0
- package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
- package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
- package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
- package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
- package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
- package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
- package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
- package/server-dist/server/harness/usage.js +82 -0
- package/server-dist/server/hooks.js +222 -0
- package/server-dist/server/http.js +266 -0
- package/server-dist/server/index.js +77 -0
- package/server-dist/server/integrations.js +292 -0
- package/server-dist/server/pollers.js +332 -0
- package/server-dist/server/processes.js +123 -0
- package/server-dist/server/psmux.js +120 -0
- package/server-dist/server/pty.js +93 -0
- package/server-dist/server/remote.js +216 -0
- package/server-dist/server/sessionReaper.js +101 -0
- package/server-dist/server/sessions.js +315 -0
- package/server-dist/server/settings.js +125 -0
- package/server-dist/server/skill.js +29 -0
- package/server-dist/server/tmux.js +217 -0
- package/server-dist/server/transcripts.js +334 -0
- package/server-dist/server/util.js +286 -0
- package/server-dist/src/types/index.js +53 -0
- package/README.md +0 -3
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Port of coordination_store.rs — links.json, the card store.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Mutex, devboardDataDir, ksuid, log, writeFileAtomic } from "./util.js";
|
|
5
|
+
const nowIso = () => new Date().toISOString();
|
|
6
|
+
const emptyOverrides = () => ({
|
|
7
|
+
worktreePath: false,
|
|
8
|
+
tmuxSession: false,
|
|
9
|
+
name: false,
|
|
10
|
+
column: false,
|
|
11
|
+
prLink: false,
|
|
12
|
+
issueLink: false,
|
|
13
|
+
});
|
|
14
|
+
function normalizeLink(l) {
|
|
15
|
+
// serde(default) equivalents so older / macOS-written files load cleanly.
|
|
16
|
+
l.manualOverrides = Object.assign(emptyOverrides(), l.manualOverrides);
|
|
17
|
+
l.manuallyArchived ??= false;
|
|
18
|
+
l.source ??= "discovered";
|
|
19
|
+
l.prLinks ??= [];
|
|
20
|
+
l.isRemote ??= false;
|
|
21
|
+
l.assistantId ??= "claude";
|
|
22
|
+
// The Waiting lane was merged into In Progress; older files still carry its id.
|
|
23
|
+
if (l.column === "requires_attention")
|
|
24
|
+
l.column = "in_progress";
|
|
25
|
+
return l;
|
|
26
|
+
}
|
|
27
|
+
export function newLinkBase() {
|
|
28
|
+
const now = nowIso();
|
|
29
|
+
return {
|
|
30
|
+
id: ksuid("card"),
|
|
31
|
+
column: "backlog",
|
|
32
|
+
createdAt: now,
|
|
33
|
+
updatedAt: now,
|
|
34
|
+
manualOverrides: emptyOverrides(),
|
|
35
|
+
manuallyArchived: false,
|
|
36
|
+
source: "manual",
|
|
37
|
+
prLinks: [],
|
|
38
|
+
isRemote: false,
|
|
39
|
+
assistantId: "claude",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function displayTitle(link) {
|
|
43
|
+
if (link.name)
|
|
44
|
+
return link.name;
|
|
45
|
+
if (link.promptBody)
|
|
46
|
+
return Array.from(link.promptBody).slice(0, 100).join("");
|
|
47
|
+
if (link.worktreeLink?.branch)
|
|
48
|
+
return link.worktreeLink.branch;
|
|
49
|
+
const prTitle = link.prLinks[0]?.title;
|
|
50
|
+
if (prTitle)
|
|
51
|
+
return prTitle;
|
|
52
|
+
if (link.sessionLink)
|
|
53
|
+
return link.sessionLink.sessionId;
|
|
54
|
+
return link.id;
|
|
55
|
+
}
|
|
56
|
+
export class CoordinationStore {
|
|
57
|
+
filePath;
|
|
58
|
+
lock = new Mutex();
|
|
59
|
+
constructor(base) {
|
|
60
|
+
this.filePath = path.join(base ?? devboardDataDir(), "links.json");
|
|
61
|
+
}
|
|
62
|
+
async readLinks() {
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = await fs.promises.readFile(this.filePath, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(data);
|
|
72
|
+
if (parsed && Array.isArray(parsed.links))
|
|
73
|
+
return parsed.links.map(normalizeLink);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// fall through to corruption recovery
|
|
77
|
+
}
|
|
78
|
+
// Corruption recovery: keep the bad file around as .bkp (mirrors macOS).
|
|
79
|
+
const backup = this.filePath.replace(/\.json$/, ".json.bkp");
|
|
80
|
+
try {
|
|
81
|
+
await fs.promises.copyFile(this.filePath, backup);
|
|
82
|
+
log.warn("coordination", `links.json failed to parse; copied to ${backup} and resetting to empty`);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
log.error("coordination", `failed to back up corrupt links.json to ${backup}: ${e}`);
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
async writeLinks(links) {
|
|
90
|
+
await writeFileAtomic(this.filePath, JSON.stringify({ links }, null, 2));
|
|
91
|
+
}
|
|
92
|
+
/** Serialised read-modify-write. The callback mutates `links` in place; return
|
|
93
|
+
* `false` to skip the write (no-op). */
|
|
94
|
+
mutate(fn) {
|
|
95
|
+
return this.lock.run(async () => {
|
|
96
|
+
const links = await this.readLinks();
|
|
97
|
+
const result = await fn(links);
|
|
98
|
+
if (result === false)
|
|
99
|
+
return undefined;
|
|
100
|
+
await this.writeLinks(links);
|
|
101
|
+
return result;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Whole-list read-modify-write for callers that restructure the list. */
|
|
105
|
+
update(fn) {
|
|
106
|
+
return this.lock.run(async () => {
|
|
107
|
+
const links = await this.readLinks();
|
|
108
|
+
const next = await fn(links);
|
|
109
|
+
if (next === false)
|
|
110
|
+
return;
|
|
111
|
+
await this.writeLinks(next);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
upsertLink(link) {
|
|
115
|
+
return this.mutate((links) => {
|
|
116
|
+
const idx = links.findIndex((l) => l.id === link.id);
|
|
117
|
+
if (idx >= 0)
|
|
118
|
+
links[idx] = link;
|
|
119
|
+
else
|
|
120
|
+
links.push(link);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
moveCard(cardId, column) {
|
|
124
|
+
return this.mutate((links) => {
|
|
125
|
+
const link = links.find((l) => l.id === cardId);
|
|
126
|
+
if (!link)
|
|
127
|
+
return;
|
|
128
|
+
link.column = column;
|
|
129
|
+
link.manualOverrides.column = true;
|
|
130
|
+
if (column === "all_sessions") {
|
|
131
|
+
link.manuallyArchived = true;
|
|
132
|
+
delete link.pinnedAt;
|
|
133
|
+
delete link.pinnedSortOrder;
|
|
134
|
+
}
|
|
135
|
+
else if (link.manuallyArchived) {
|
|
136
|
+
link.manuallyArchived = false;
|
|
137
|
+
}
|
|
138
|
+
link.updatedAt = nowIso();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async createCard(args) {
|
|
142
|
+
const link = newLinkBase();
|
|
143
|
+
link.name = args.title ?? undefined;
|
|
144
|
+
link.projectPath = args.project;
|
|
145
|
+
link.promptBody = args.prompt;
|
|
146
|
+
link.assistantId = args.assistantId;
|
|
147
|
+
if (args.promptImagePaths)
|
|
148
|
+
link.promptImagePaths = args.promptImagePaths;
|
|
149
|
+
if (args.apiServiceId)
|
|
150
|
+
link.apiServiceId = args.apiServiceId;
|
|
151
|
+
await this.upsertLink(link);
|
|
152
|
+
return link;
|
|
153
|
+
}
|
|
154
|
+
async createIssueCard(args) {
|
|
155
|
+
const link = newLinkBase();
|
|
156
|
+
link.name = `#${args.issueNumber}: ${args.issueTitle}`;
|
|
157
|
+
link.projectPath = args.projectPath;
|
|
158
|
+
link.source = "github_issue";
|
|
159
|
+
link.promptBody = args.promptBody;
|
|
160
|
+
link.issueLink = {
|
|
161
|
+
number: args.issueNumber,
|
|
162
|
+
url: args.issueUrl,
|
|
163
|
+
title: args.issueTitle,
|
|
164
|
+
body: args.issueBody ?? undefined,
|
|
165
|
+
};
|
|
166
|
+
await this.upsertLink(link);
|
|
167
|
+
return link;
|
|
168
|
+
}
|
|
169
|
+
setCardApiService(cardId, apiServiceId) {
|
|
170
|
+
return this.mutate((links) => {
|
|
171
|
+
const link = links.find((l) => l.id === cardId);
|
|
172
|
+
if (!link)
|
|
173
|
+
return;
|
|
174
|
+
if (apiServiceId)
|
|
175
|
+
link.apiServiceId = apiServiceId;
|
|
176
|
+
else
|
|
177
|
+
delete link.apiServiceId;
|
|
178
|
+
link.updatedAt = nowIso();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
setCardRuntime(cardId, runtime) {
|
|
182
|
+
return this.mutate((links) => {
|
|
183
|
+
const link = links.find((l) => l.id === cardId);
|
|
184
|
+
if (!link)
|
|
185
|
+
return;
|
|
186
|
+
if (runtime)
|
|
187
|
+
link.cardRuntime = runtime;
|
|
188
|
+
else
|
|
189
|
+
delete link.cardRuntime;
|
|
190
|
+
link.updatedAt = nowIso();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Sessions whose card the user deleted; discovery must not resurrect them. */
|
|
194
|
+
get dismissedPath() {
|
|
195
|
+
return path.join(path.dirname(this.filePath), "dismissed-sessions.json");
|
|
196
|
+
}
|
|
197
|
+
async readDismissedSessions() {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(await fs.promises.readFile(this.dismissedPath, "utf8"));
|
|
200
|
+
return new Set(Array.isArray(parsed?.sessionIds) ? parsed.sessionIds : []);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return new Set();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
removeLink(cardId) {
|
|
207
|
+
return this.lock.run(async () => {
|
|
208
|
+
const links = await this.readLinks();
|
|
209
|
+
const removed = links.find((l) => l.id === cardId);
|
|
210
|
+
const sessionId = removed?.sessionLink?.sessionId;
|
|
211
|
+
if (sessionId) {
|
|
212
|
+
const dismissed = await this.readDismissedSessions();
|
|
213
|
+
dismissed.add(sessionId);
|
|
214
|
+
await writeFileAtomic(this.dismissedPath, JSON.stringify({ sessionIds: [...dismissed] }, null, 2));
|
|
215
|
+
}
|
|
216
|
+
await this.writeLinks(links.filter((l) => l.id !== cardId));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
archiveLink(cardId) {
|
|
220
|
+
return this.mutate((links) => {
|
|
221
|
+
const link = links.find((l) => l.id === cardId);
|
|
222
|
+
if (!link)
|
|
223
|
+
return;
|
|
224
|
+
link.manuallyArchived = true;
|
|
225
|
+
link.column = "all_sessions";
|
|
226
|
+
delete link.pinnedAt;
|
|
227
|
+
delete link.pinnedSortOrder;
|
|
228
|
+
link.updatedAt = nowIso();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
renameLink(cardId, name) {
|
|
232
|
+
return this.mutate((links) => {
|
|
233
|
+
const link = links.find((l) => l.id === cardId);
|
|
234
|
+
if (!link)
|
|
235
|
+
return;
|
|
236
|
+
link.name = name;
|
|
237
|
+
link.manualOverrides.name = true;
|
|
238
|
+
link.updatedAt = nowIso();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** Edit a card's task: title (empty clears it) and the prompt it launches with. */
|
|
242
|
+
updateCardTask(cardId, args) {
|
|
243
|
+
return this.mutate((links) => {
|
|
244
|
+
const link = links.find((l) => l.id === cardId);
|
|
245
|
+
if (!link)
|
|
246
|
+
return;
|
|
247
|
+
const name = args.name.trim();
|
|
248
|
+
if (name) {
|
|
249
|
+
link.name = name;
|
|
250
|
+
link.manualOverrides.name = true;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
delete link.name;
|
|
254
|
+
link.manualOverrides.name = false;
|
|
255
|
+
}
|
|
256
|
+
link.promptBody = args.promptBody;
|
|
257
|
+
if (args.promptImagePaths && args.promptImagePaths.length > 0)
|
|
258
|
+
link.promptImagePaths = args.promptImagePaths;
|
|
259
|
+
else
|
|
260
|
+
delete link.promptImagePaths;
|
|
261
|
+
link.updatedAt = nowIso();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async addQueuedPrompt(cardId, prompt) {
|
|
265
|
+
await this.mutate((links) => {
|
|
266
|
+
const link = links.find((l) => l.id === cardId);
|
|
267
|
+
if (!link)
|
|
268
|
+
return;
|
|
269
|
+
(link.queuedPrompts ??= []).push(prompt);
|
|
270
|
+
link.updatedAt = nowIso();
|
|
271
|
+
});
|
|
272
|
+
return prompt;
|
|
273
|
+
}
|
|
274
|
+
/** `imagePaths`: undefined = keep, null/[] = clear, array = replace. */
|
|
275
|
+
updateQueuedPrompt(cardId, promptId, body, sendAutomatically, imagePaths) {
|
|
276
|
+
return this.mutate((links) => {
|
|
277
|
+
const link = links.find((l) => l.id === cardId);
|
|
278
|
+
if (!link)
|
|
279
|
+
return;
|
|
280
|
+
const p = link.queuedPrompts?.find((q) => q.id === promptId);
|
|
281
|
+
if (p) {
|
|
282
|
+
p.body = body;
|
|
283
|
+
p.sendAutomatically = sendAutomatically;
|
|
284
|
+
if (imagePaths !== undefined) {
|
|
285
|
+
if (imagePaths && imagePaths.length > 0)
|
|
286
|
+
p.imagePaths = imagePaths;
|
|
287
|
+
else
|
|
288
|
+
delete p.imagePaths;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
link.updatedAt = nowIso();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
removeQueuedPrompt(cardId, promptId) {
|
|
295
|
+
return this.mutate((links) => {
|
|
296
|
+
const link = links.find((l) => l.id === cardId);
|
|
297
|
+
if (!link)
|
|
298
|
+
return;
|
|
299
|
+
if (link.queuedPrompts) {
|
|
300
|
+
link.queuedPrompts = link.queuedPrompts.filter((p) => p.id !== promptId);
|
|
301
|
+
if (link.queuedPrompts.length === 0)
|
|
302
|
+
delete link.queuedPrompts;
|
|
303
|
+
}
|
|
304
|
+
link.updatedAt = nowIso();
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/** Manual ordering; deliberately does not bump updatedAt. */
|
|
308
|
+
reorderCards(orderedIds) {
|
|
309
|
+
return this.mutate((links) => {
|
|
310
|
+
orderedIds.forEach((id, idx) => {
|
|
311
|
+
const link = links.find((l) => l.id === id);
|
|
312
|
+
if (link)
|
|
313
|
+
link.sortOrder = idx;
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** Idempotent self-compact nudge. Returns whether a prompt was added. */
|
|
318
|
+
async enqueueSelfCompactPrompt(cardId, body, thresholdTokens) {
|
|
319
|
+
const added = await this.mutate((links) => {
|
|
320
|
+
const link = links.find((l) => l.id === cardId);
|
|
321
|
+
if (!link)
|
|
322
|
+
return false;
|
|
323
|
+
const trimmed = body.trim();
|
|
324
|
+
for (const p of link.queuedPrompts ?? []) {
|
|
325
|
+
if (p.selfCompactThresholdTokens === thresholdTokens)
|
|
326
|
+
return false;
|
|
327
|
+
if (p.selfCompactThresholdTokens === undefined && p.body.trim() === trimmed)
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
(link.queuedPrompts ??= []).push({
|
|
331
|
+
id: ksuid("prompt"),
|
|
332
|
+
body,
|
|
333
|
+
sendAutomatically: true,
|
|
334
|
+
selfCompactThresholdTokens: thresholdTokens,
|
|
335
|
+
});
|
|
336
|
+
link.updatedAt = nowIso();
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
return added === true;
|
|
340
|
+
}
|
|
341
|
+
async addBrowserTab(cardId, url) {
|
|
342
|
+
const tab = { id: ksuid("browser"), url };
|
|
343
|
+
const res = await this.mutate((links) => {
|
|
344
|
+
const link = links.find((l) => l.id === cardId);
|
|
345
|
+
if (!link)
|
|
346
|
+
return false;
|
|
347
|
+
(link.browserTabs ??= []).push(tab);
|
|
348
|
+
link.updatedAt = nowIso();
|
|
349
|
+
return true;
|
|
350
|
+
});
|
|
351
|
+
return res ? tab : null;
|
|
352
|
+
}
|
|
353
|
+
async removeBrowserTab(cardId, tabId) {
|
|
354
|
+
const res = await this.mutate((links) => {
|
|
355
|
+
const link = links.find((l) => l.id === cardId);
|
|
356
|
+
if (!link?.browserTabs)
|
|
357
|
+
return false;
|
|
358
|
+
const before = link.browserTabs.length;
|
|
359
|
+
link.browserTabs = link.browserTabs.filter((t) => t.id !== tabId);
|
|
360
|
+
if (link.browserTabs.length === before)
|
|
361
|
+
return false;
|
|
362
|
+
if (link.browserTabs.length === 0)
|
|
363
|
+
delete link.browserTabs;
|
|
364
|
+
link.updatedAt = nowIso();
|
|
365
|
+
return true;
|
|
366
|
+
});
|
|
367
|
+
return res === true;
|
|
368
|
+
}
|
|
369
|
+
reorderBrowserTabs(cardId, orderedIds) {
|
|
370
|
+
return this.mutate((links) => {
|
|
371
|
+
const link = links.find((l) => l.id === cardId);
|
|
372
|
+
if (!link?.browserTabs)
|
|
373
|
+
return false;
|
|
374
|
+
const rank = (id) => {
|
|
375
|
+
const i = orderedIds.indexOf(id);
|
|
376
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
377
|
+
};
|
|
378
|
+
// Array.prototype.sort is stable, so unmentioned tabs keep relative order.
|
|
379
|
+
link.browserTabs.sort((a, b) => rank(a.id) - rank(b.id));
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** `title`: undefined = keep, null = clear, string = replace. */
|
|
383
|
+
async updateBrowserTab(cardId, tabId, url, title) {
|
|
384
|
+
const res = await this.mutate((links) => {
|
|
385
|
+
const link = links.find((l) => l.id === cardId);
|
|
386
|
+
const tab = link?.browserTabs?.find((t) => t.id === tabId);
|
|
387
|
+
if (!link || !tab)
|
|
388
|
+
return false;
|
|
389
|
+
if (url)
|
|
390
|
+
tab.url = url;
|
|
391
|
+
if (title !== undefined) {
|
|
392
|
+
if (title === null)
|
|
393
|
+
delete tab.title;
|
|
394
|
+
else
|
|
395
|
+
tab.title = title;
|
|
396
|
+
}
|
|
397
|
+
link.updatedAt = nowIso();
|
|
398
|
+
return true;
|
|
399
|
+
});
|
|
400
|
+
return res === true;
|
|
401
|
+
}
|
|
402
|
+
setCardPinned(cardId, isPinned) {
|
|
403
|
+
return this.mutate((links) => {
|
|
404
|
+
const orders = links.map((l) => l.pinnedSortOrder).filter((o) => typeof o === "number");
|
|
405
|
+
const firstOrder = orders.length ? Math.min(...orders) : 0;
|
|
406
|
+
const link = links.find((l) => l.id === cardId);
|
|
407
|
+
if (!link)
|
|
408
|
+
return false;
|
|
409
|
+
if (isPinned) {
|
|
410
|
+
if (link.pinnedAt)
|
|
411
|
+
return false;
|
|
412
|
+
link.pinnedAt = nowIso();
|
|
413
|
+
link.pinnedSortOrder = firstOrder - 1;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
if (!link.pinnedAt)
|
|
417
|
+
return false;
|
|
418
|
+
delete link.pinnedAt;
|
|
419
|
+
delete link.pinnedSortOrder;
|
|
420
|
+
}
|
|
421
|
+
link.updatedAt = nowIso();
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/** Stamps lastOpenedAt without bumping updatedAt. */
|
|
425
|
+
markCardOpened(cardId) {
|
|
426
|
+
return this.mutate((links) => {
|
|
427
|
+
const link = links.find((l) => l.id === cardId);
|
|
428
|
+
if (!link)
|
|
429
|
+
return false;
|
|
430
|
+
link.lastOpenedAt = nowIso();
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
reorderPinnedCards(orderedIds) {
|
|
434
|
+
return this.mutate((links) => {
|
|
435
|
+
orderedIds.forEach((id, idx) => {
|
|
436
|
+
const link = links.find((l) => l.id === id);
|
|
437
|
+
if (link?.pinnedAt)
|
|
438
|
+
link.pinnedSortOrder = idx;
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Open a path in the user's editor on the machine the server runs on.
|
|
2
|
+
// Ports shell_command.rs::open_in_editor (the terminal-launching halves of that
|
|
3
|
+
// module are gone: terminals now live in the browser).
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { IS_WINDOWS, run, spawnDetached } from "./util.js";
|
|
10
|
+
import { isWsl } from "./sessions.js";
|
|
11
|
+
const isWslUncPath = (p) => p.startsWith("\\\\wsl") || p.includes("\\wsl$\\") || p.includes("\\wsl.localhost\\");
|
|
12
|
+
/** `\\wsl$\Distro\home\u` or `\\wsl.localhost\Distro\home\u` → `/home/u`. */
|
|
13
|
+
export function uncToLinuxPath(p) {
|
|
14
|
+
const n = p.replaceAll("\\", "/");
|
|
15
|
+
for (const prefix of ["//wsl.localhost/", "//wsl$/"]) {
|
|
16
|
+
if (n.startsWith(prefix)) {
|
|
17
|
+
const rest = n.slice(prefix.length);
|
|
18
|
+
const slash = rest.indexOf("/");
|
|
19
|
+
if (slash >= 0)
|
|
20
|
+
return rest.slice(slash);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
const cmdQuote = (a) => (/[\s;&|<>^"]/.test(a) ? `"${a.replaceAll('"', '""')}"` : a);
|
|
26
|
+
/** Open a new terminal window (outside the browser) running the given command. */
|
|
27
|
+
export function openInTerminal(spec) {
|
|
28
|
+
if (!IS_WINDOWS)
|
|
29
|
+
throw new Error("opening an external terminal is only supported on Windows");
|
|
30
|
+
let args;
|
|
31
|
+
if (spec.wslScript) {
|
|
32
|
+
// Base64 sidesteps every layer of quoting between the terminal, wsl.exe and bash.
|
|
33
|
+
const b64 = Buffer.from(spec.wslScript, "utf8").toString("base64");
|
|
34
|
+
args = ["wsl.exe", "--", "bash", "-lc", `echo ${b64} | base64 -d | bash`];
|
|
35
|
+
}
|
|
36
|
+
else if (spec.argv?.length) {
|
|
37
|
+
args = spec.argv;
|
|
38
|
+
}
|
|
39
|
+
else if (spec.cmdLine) {
|
|
40
|
+
// A script file avoids cmd.exe's quote-stripping rules for `/k "…"`.
|
|
41
|
+
const file = path.join(os.tmpdir(), `devboard-term-${randomUUID()}.cmd`);
|
|
42
|
+
fs.writeFileSync(file, `@echo off\r\n${spec.cmdLine}\r\n`);
|
|
43
|
+
args = ["cmd.exe", "/k", file];
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
throw new Error("nothing to run in the terminal");
|
|
47
|
+
}
|
|
48
|
+
// Preferred: a tab in one shared, named Windows Terminal window. `;` is wt's
|
|
49
|
+
// own command separator, so escape it.
|
|
50
|
+
const wt = spawn("wt.exe", ["-w", "devboard", "new-tab", ...args.map((a) => a.replaceAll(";", "\\;"))], {
|
|
51
|
+
detached: true,
|
|
52
|
+
stdio: "ignore",
|
|
53
|
+
});
|
|
54
|
+
wt.on("error", () => {
|
|
55
|
+
// No Windows Terminal: `start` gives the child its own console window.
|
|
56
|
+
const child = spawn("cmd.exe", ["/c", "start", '""', ...args.map(cmdQuote)], {
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: "ignore",
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
windowsVerbatimArguments: true,
|
|
61
|
+
});
|
|
62
|
+
child.on("error", () => { });
|
|
63
|
+
child.unref();
|
|
64
|
+
});
|
|
65
|
+
wt.unref();
|
|
66
|
+
}
|
|
67
|
+
export async function openInEditor(target, editor) {
|
|
68
|
+
const editorCmd = editor || process.env.EDITOR || "code";
|
|
69
|
+
if (!IS_WINDOWS && isWsl()) {
|
|
70
|
+
// Windows-side editor, with the Linux path converted via wslpath.
|
|
71
|
+
const conv = await run("wslpath", ["-w", target]);
|
|
72
|
+
const openPath = conv.ok && conv.stdout.trim() ? conv.stdout.trim() : target;
|
|
73
|
+
if (spawnDetached(`${editorCmd}.cmd`, [openPath]) || spawnDetached(editorCmd, [openPath]))
|
|
74
|
+
return;
|
|
75
|
+
throw new Error(`open in editor '${editorCmd}' failed`);
|
|
76
|
+
}
|
|
77
|
+
if (IS_WINDOWS) {
|
|
78
|
+
const local = process.env.LOCALAPPDATA ?? "";
|
|
79
|
+
const programFiles = process.env.ProgramFiles ?? String.raw `C:\Program Files`;
|
|
80
|
+
const editors = [
|
|
81
|
+
`${local}\\Programs\\cursor\\resources\\app\\bin\\cursor.cmd`,
|
|
82
|
+
`${programFiles}\\Microsoft VS Code\\bin\\code.cmd`,
|
|
83
|
+
`${local}\\Programs\\Microsoft VS Code\\bin\\code.cmd`,
|
|
84
|
+
].filter((p) => fs.existsSync(p));
|
|
85
|
+
if (isWslUncPath(target) || target.startsWith("/")) {
|
|
86
|
+
const linuxPath = target.startsWith("/") ? target : uncToLinuxPath(target);
|
|
87
|
+
const folderUri = `vscode-remote://wsl+Ubuntu${linuxPath}`;
|
|
88
|
+
for (const ed of editors) {
|
|
89
|
+
if (spawnDetached("cmd", ["/c", ed, "--folder-uri", folderUri]))
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const ed of editors) {
|
|
94
|
+
if (spawnDetached("cmd", ["/c", ed, target]))
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
throw new Error("could not open editor; install Cursor or VS Code");
|
|
98
|
+
}
|
|
99
|
+
if (!spawnDetached(editorCmd, [target]))
|
|
100
|
+
throw new Error(`open in editor '${editorCmd}' failed`);
|
|
101
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// In-process event bus. The HTTP layer subscribes once and fans every event
|
|
2
|
+
// out to connected browsers over WebSocket (replaces Tauri's app.emit).
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
export function emit(event, payload) {
|
|
5
|
+
for (const l of listeners) {
|
|
6
|
+
try {
|
|
7
|
+
l(event, payload);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// a broken subscriber must not stop the others
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function subscribe(listener) {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => listeners.delete(listener);
|
|
17
|
+
}
|