pinokiod 7.2.7 → 7.2.8
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/kernel/api/index.js +2 -0
- package/kernel/api/shell_run_template.js +273 -0
- package/kernel/shell.js +21 -2
- package/package.json +1 -1
- package/server/index.js +65 -5
- package/server/lib/drafts.js +376 -0
- package/server/lib/workspace_catalog.js +151 -0
- package/server/lib/workspace_runtime.js +390 -0
- package/server/public/common.js +8 -0
- package/server/public/drafts.js +632 -0
- package/server/routes/draft_import.js +469 -0
- package/server/routes/workspaces.js +44 -0
- package/server/socket.js +22 -11
- package/server/views/app.ejs +13 -0
- package/server/views/partials/main_sidebar.ejs +1 -0
- package/server/views/partials/workspace_row.ejs +61 -0
- package/server/views/terminal.ejs +6 -0
- package/server/views/terminals.ejs +1 -0
- package/server/views/workspaces.ejs +812 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const ParcelWatcher = require("@parcel/watcher");
|
|
5
|
+
|
|
6
|
+
const RESULT_RELATIVE_DIR = path.join(".pinokio", "draft");
|
|
7
|
+
const POST_FILENAME = "post.md";
|
|
8
|
+
const READY_FILENAME = "READY";
|
|
9
|
+
const STATE_FILENAME = "drafts.json";
|
|
10
|
+
const MAX_PREVIEW_BYTES = 256 * 1024;
|
|
11
|
+
const PREVIEW_CHARS = 1200;
|
|
12
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
13
|
+
".apng",
|
|
14
|
+
".avif",
|
|
15
|
+
".gif",
|
|
16
|
+
".jpeg",
|
|
17
|
+
".jpg",
|
|
18
|
+
".m4a",
|
|
19
|
+
".mp3",
|
|
20
|
+
".mp4",
|
|
21
|
+
".ogg",
|
|
22
|
+
".png",
|
|
23
|
+
".svg",
|
|
24
|
+
".wav",
|
|
25
|
+
".webm",
|
|
26
|
+
".webp"
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function createHash(value) {
|
|
30
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex").slice(0, 24);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeWhitespace(value) {
|
|
34
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractTitle(markdown, workspaceName) {
|
|
38
|
+
const lines = String(markdown || "").split(/\r?\n/);
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const match = line.match(/^#\s+(.+?)\s*#*\s*$/);
|
|
41
|
+
if (match && match[1]) {
|
|
42
|
+
return normalizeWhitespace(match[1]).slice(0, 140) || "Draft";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return workspaceName ? `Draft for ${workspaceName}` : "Draft";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildExcerpt(markdown) {
|
|
49
|
+
const stripped = String(markdown || "")
|
|
50
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
51
|
+
.replace(/!\[[^\]]*]\([^)]+\)/g, " ")
|
|
52
|
+
.replace(/\[[^\]]+]\([^)]+\)/g, (match) => {
|
|
53
|
+
const label = match.match(/^\[([^\]]+)]/);
|
|
54
|
+
return label && label[1] ? ` ${label[1]} ` : " ";
|
|
55
|
+
})
|
|
56
|
+
.replace(/^#+\s+/gm, "")
|
|
57
|
+
.replace(/[*_`>#-]/g, " ");
|
|
58
|
+
return normalizeWhitespace(stripped).slice(0, PREVIEW_CHARS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isExternalRef(value) {
|
|
62
|
+
return /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeMarkdownRef(value) {
|
|
66
|
+
const raw = String(value || "").trim().replace(/^<|>$/g, "");
|
|
67
|
+
if (!raw || raw.includes("\0") || isExternalRef(raw) || path.isAbsolute(raw)) {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
const withoutHash = raw.split("#")[0];
|
|
71
|
+
const withoutQuery = withoutHash.split("?")[0];
|
|
72
|
+
if (!withoutQuery) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
let decoded = withoutQuery;
|
|
76
|
+
try {
|
|
77
|
+
decoded = decodeURIComponent(withoutQuery);
|
|
78
|
+
} catch (_) {
|
|
79
|
+
}
|
|
80
|
+
const normalized = path.posix.normalize(decoded.replace(/\\/g, "/"));
|
|
81
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectMarkdownRefs(markdown) {
|
|
88
|
+
const refs = [];
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const patterns = [
|
|
91
|
+
/!\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g,
|
|
92
|
+
/\[(?:video|audio|media|image|screenshot|file|asset)[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/gi,
|
|
93
|
+
/\[[^\]]+]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
|
|
94
|
+
];
|
|
95
|
+
for (const pattern of patterns) {
|
|
96
|
+
let match = null;
|
|
97
|
+
while ((match = pattern.exec(markdown))) {
|
|
98
|
+
const ref = normalizeMarkdownRef(match[1]);
|
|
99
|
+
if (!ref || seen.has(ref)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
seen.add(ref);
|
|
103
|
+
refs.push(ref);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return refs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function describeMediaRefs(markdown, baseDir) {
|
|
110
|
+
const refs = collectMarkdownRefs(markdown);
|
|
111
|
+
const media = [];
|
|
112
|
+
for (const ref of refs) {
|
|
113
|
+
const ext = path.extname(ref).toLowerCase();
|
|
114
|
+
if (!MEDIA_EXTENSIONS.has(ext)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const filePath = path.resolve(baseDir, ref);
|
|
118
|
+
const relative = path.relative(baseDir, filePath);
|
|
119
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const stats = await fs.promises.stat(filePath).catch(() => null);
|
|
123
|
+
media.push({
|
|
124
|
+
ref,
|
|
125
|
+
path: filePath,
|
|
126
|
+
bytes: stats && stats.isFile() ? stats.size : null,
|
|
127
|
+
exists: Boolean(stats && stats.isFile())
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return media;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isRelevantEvent(workspacePath, eventPath) {
|
|
134
|
+
const relative = path.relative(workspacePath, eventPath || "");
|
|
135
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const normalized = relative.split(path.sep).join("/");
|
|
139
|
+
return normalized === ".pinokio/draft"
|
|
140
|
+
|| normalized.startsWith(".pinokio/draft/");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createDraftService({ kernel, taskWorkspaceLinks }) {
|
|
144
|
+
if (!kernel) {
|
|
145
|
+
throw new Error("kernel is required");
|
|
146
|
+
}
|
|
147
|
+
if (!taskWorkspaceLinks) {
|
|
148
|
+
throw new Error("taskWorkspaceLinks is required");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const statePath = () => path.resolve(kernel.path("tasks"), STATE_FILENAME);
|
|
152
|
+
const resultsByWorkspace = new Map();
|
|
153
|
+
const watchersByWorkspace = new Map();
|
|
154
|
+
const dismissedIds = new Set();
|
|
155
|
+
let started = false;
|
|
156
|
+
let stateLoaded = false;
|
|
157
|
+
|
|
158
|
+
async function ensureStateLoaded() {
|
|
159
|
+
if (stateLoaded) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
stateLoaded = true;
|
|
163
|
+
try {
|
|
164
|
+
const raw = await fs.promises.readFile(statePath(), "utf8");
|
|
165
|
+
const parsed = JSON.parse(raw);
|
|
166
|
+
if (parsed && Array.isArray(parsed.dismissed)) {
|
|
167
|
+
parsed.dismissed.forEach((id) => {
|
|
168
|
+
if (typeof id === "string" && id.trim()) {
|
|
169
|
+
dismissedIds.add(id.trim());
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
} catch (_) {
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function saveState() {
|
|
178
|
+
await fs.promises.mkdir(path.dirname(statePath()), { recursive: true });
|
|
179
|
+
const payload = {
|
|
180
|
+
version: 1,
|
|
181
|
+
dismissed: Array.from(dismissedIds).slice(-500)
|
|
182
|
+
};
|
|
183
|
+
await fs.promises.writeFile(statePath(), JSON.stringify(payload, null, 2));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readMarkdownPreview(postPath) {
|
|
187
|
+
const handle = await fs.promises.open(postPath, "r");
|
|
188
|
+
try {
|
|
189
|
+
const buffer = Buffer.alloc(MAX_PREVIEW_BYTES);
|
|
190
|
+
const read = await handle.read(buffer, 0, MAX_PREVIEW_BYTES, 0);
|
|
191
|
+
return buffer.slice(0, read.bytesRead).toString("utf8");
|
|
192
|
+
} finally {
|
|
193
|
+
await handle.close().catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function inspectWorkspace({ taskId, ref, cwd }) {
|
|
198
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const workspacePath = path.resolve(cwd.trim());
|
|
202
|
+
const resultDir = path.join(workspacePath, RESULT_RELATIVE_DIR);
|
|
203
|
+
const readyPath = path.join(resultDir, READY_FILENAME);
|
|
204
|
+
const postPath = path.join(resultDir, POST_FILENAME);
|
|
205
|
+
const readyStats = await fs.promises.stat(readyPath).catch(() => null);
|
|
206
|
+
const postStats = await fs.promises.stat(postPath).catch(() => null);
|
|
207
|
+
if (!readyStats || !readyStats.isFile() || !postStats || !postStats.isFile()) {
|
|
208
|
+
resultsByWorkspace.delete(workspacePath);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const markdown = await readMarkdownPreview(postPath);
|
|
213
|
+
const workspaceName = path.basename(workspacePath);
|
|
214
|
+
const media = await describeMediaRefs(markdown, resultDir);
|
|
215
|
+
const updatedAtMs = Math.max(readyStats.mtimeMs || 0, postStats.mtimeMs || 0);
|
|
216
|
+
const id = createHash(`${workspacePath}|${postStats.size}|${postStats.mtimeMs}|${readyStats.mtimeMs}`);
|
|
217
|
+
const result = {
|
|
218
|
+
id,
|
|
219
|
+
taskId,
|
|
220
|
+
ref,
|
|
221
|
+
cwd: workspacePath,
|
|
222
|
+
workspaceName,
|
|
223
|
+
title: extractTitle(markdown, workspaceName),
|
|
224
|
+
excerpt: buildExcerpt(markdown),
|
|
225
|
+
postPath,
|
|
226
|
+
readyPath,
|
|
227
|
+
postBytes: postStats.size,
|
|
228
|
+
mediaCount: media.length,
|
|
229
|
+
missingMediaCount: media.filter((item) => !item.exists).length,
|
|
230
|
+
mediaBytes: media.reduce((total, item) => total + (Number.isFinite(item.bytes) ? item.bytes : 0), 0),
|
|
231
|
+
updatedAt: new Date(updatedAtMs || Date.now()).toISOString()
|
|
232
|
+
};
|
|
233
|
+
resultsByWorkspace.set(workspacePath, result);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scheduleInspect(taskId, ref, cwd) {
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
inspectWorkspace({ taskId, ref, cwd }).catch((error) => {
|
|
240
|
+
console.warn("[drafts] failed to inspect workspace", error && error.message ? error.message : error);
|
|
241
|
+
});
|
|
242
|
+
}, 250);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function ensureWatcher(taskId, ref, cwd) {
|
|
246
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const workspacePath = path.resolve(cwd.trim());
|
|
250
|
+
if (watchersByWorkspace.has(workspacePath)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const stats = await fs.promises.stat(workspacePath).catch(() => null);
|
|
254
|
+
if (!stats || !stats.isDirectory()) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const subscription = await ParcelWatcher.subscribe(
|
|
259
|
+
workspacePath,
|
|
260
|
+
(error, events) => {
|
|
261
|
+
if (error) {
|
|
262
|
+
console.warn("[drafts] watcher error", error && error.message ? error.message : error);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!Array.isArray(events) || !events.some((event) => isRelevantEvent(workspacePath, event && event.path))) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
scheduleInspect(taskId, ref, workspacePath);
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
ignore: [
|
|
272
|
+
"**/.git/**",
|
|
273
|
+
"**/node_modules/**",
|
|
274
|
+
"**/__pycache__/**",
|
|
275
|
+
"**/.venv/**",
|
|
276
|
+
"**/venv/**",
|
|
277
|
+
"**/env/**"
|
|
278
|
+
]
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
watchersByWorkspace.set(workspacePath, subscription);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.warn("[drafts] failed to watch workspace", error && error.message ? error.message : error);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function refreshLinkedWorkspaces() {
|
|
288
|
+
await ensureStateLoaded();
|
|
289
|
+
const registry = await taskWorkspaceLinks.readRegistry();
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
const tasks = registry && registry.tasks && typeof registry.tasks === "object" ? registry.tasks : {};
|
|
292
|
+
for (const [taskId, entry] of Object.entries(tasks)) {
|
|
293
|
+
const workspaces = entry && Array.isArray(entry.workspaces) ? entry.workspaces : [];
|
|
294
|
+
for (const workspace of workspaces) {
|
|
295
|
+
const ref = workspace && typeof workspace.ref === "string" ? workspace.ref : "";
|
|
296
|
+
const cwd = taskWorkspaceLinks.resolveWorkspaceRef(ref);
|
|
297
|
+
if (!cwd) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const workspacePath = path.resolve(cwd);
|
|
301
|
+
seen.add(workspacePath);
|
|
302
|
+
await ensureWatcher(taskId, ref, workspacePath);
|
|
303
|
+
await inspectWorkspace({ taskId, ref, cwd: workspacePath });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const workspacePath of Array.from(resultsByWorkspace.keys())) {
|
|
307
|
+
if (!seen.has(workspacePath)) {
|
|
308
|
+
resultsByWorkspace.delete(workspacePath);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function start() {
|
|
314
|
+
if (started) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
started = true;
|
|
318
|
+
await refreshLinkedWorkspaces();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function trackWorkspace({ taskId, ref }) {
|
|
322
|
+
await ensureStateLoaded();
|
|
323
|
+
const cwd = taskWorkspaceLinks.resolveWorkspaceRef(ref);
|
|
324
|
+
if (!cwd) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
await ensureWatcher(taskId, ref, cwd);
|
|
328
|
+
return inspectWorkspace({ taskId, ref, cwd });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function listPending(options = {}) {
|
|
332
|
+
await refreshLinkedWorkspaces();
|
|
333
|
+
const filterCwd = typeof options.cwd === "string" && options.cwd.trim()
|
|
334
|
+
? path.resolve(options.cwd.trim())
|
|
335
|
+
: "";
|
|
336
|
+
return Array.from(resultsByWorkspace.values())
|
|
337
|
+
.filter((result) => !dismissedIds.has(result.id))
|
|
338
|
+
.filter((result) => !filterCwd || result.cwd === filterCwd)
|
|
339
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function dismiss(id) {
|
|
343
|
+
await ensureStateLoaded();
|
|
344
|
+
const normalizedId = typeof id === "string" ? id.trim() : "";
|
|
345
|
+
if (!normalizedId) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
dismissedIds.add(normalizedId);
|
|
349
|
+
await saveState();
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function stop() {
|
|
354
|
+
for (const subscription of watchersByWorkspace.values()) {
|
|
355
|
+
if (subscription && typeof subscription.unsubscribe === "function") {
|
|
356
|
+
await subscription.unsubscribe().catch(() => {});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
watchersByWorkspace.clear();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
RESULT_RELATIVE_DIR,
|
|
364
|
+
dismiss,
|
|
365
|
+
inspectWorkspace,
|
|
366
|
+
listPending,
|
|
367
|
+
refreshLinkedWorkspaces,
|
|
368
|
+
start,
|
|
369
|
+
stop,
|
|
370
|
+
trackWorkspace
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
module.exports = {
|
|
375
|
+
createDraftService
|
|
376
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const fs = require("fs")
|
|
2
|
+
const path = require("path")
|
|
3
|
+
|
|
4
|
+
const SORT_MODES = new Set(["most_used", "last_opened", "az"])
|
|
5
|
+
|
|
6
|
+
function normalizeSortMode(sort) {
|
|
7
|
+
if (SORT_MODES.has(sort)) return sort
|
|
8
|
+
return "most_used"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizePathKey(filepath) {
|
|
12
|
+
const resolved = path.resolve(filepath).replace(/[\\/]+$/, "")
|
|
13
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toRoutePath(filepath) {
|
|
17
|
+
const resolved = path.resolve(filepath).replace(/\\/g, "/")
|
|
18
|
+
const encoded = resolved
|
|
19
|
+
.split("/")
|
|
20
|
+
.map((segment, index) => {
|
|
21
|
+
if (index === 0 && segment === "") return ""
|
|
22
|
+
return encodeURIComponent(segment)
|
|
23
|
+
})
|
|
24
|
+
.join("/")
|
|
25
|
+
return encoded.startsWith("/") ? `/d${encoded}` : `/d/${encoded}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function latestTimestamp(values) {
|
|
29
|
+
return values.reduce((latest, value) => {
|
|
30
|
+
if (!value) return latest
|
|
31
|
+
const timestamp = typeof value === "number" ? value : new Date(value).getTime()
|
|
32
|
+
return Number.isFinite(timestamp) && timestamp > latest ? timestamp : latest
|
|
33
|
+
}, 0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sortWorkspaces(items, sort) {
|
|
37
|
+
const mode = normalizeSortMode(sort)
|
|
38
|
+
const sorted = [...items]
|
|
39
|
+
sorted.sort((a, b) => {
|
|
40
|
+
if (mode === "az") {
|
|
41
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
42
|
+
}
|
|
43
|
+
if (mode === "last_opened") {
|
|
44
|
+
const delta = (b.lastOpenedAtMs || b.modifiedAtMs || 0) - (a.lastOpenedAtMs || a.modifiedAtMs || 0)
|
|
45
|
+
if (delta !== 0) return delta
|
|
46
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
47
|
+
}
|
|
48
|
+
const usageDelta = (b.usageCount || 0) - (a.usageCount || 0)
|
|
49
|
+
if (usageDelta !== 0) return usageDelta
|
|
50
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
51
|
+
})
|
|
52
|
+
return sorted
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function newestDraft(drafts) {
|
|
56
|
+
return [...drafts].sort((a, b) => {
|
|
57
|
+
return (new Date(b.updatedAt || 0).getTime() || 0) - (new Date(a.updatedAt || 0).getTime() || 0)
|
|
58
|
+
})[0] || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createWorkspaceCatalogService({ kernel, workspaceRuntime, drafts }) {
|
|
62
|
+
async function list(options = {}) {
|
|
63
|
+
const sort = normalizeSortMode(options.sort)
|
|
64
|
+
const root = path.resolve(kernel.path("workspaces"))
|
|
65
|
+
const entries = await fs.promises.readdir(root, { withFileTypes: true }).catch(() => [])
|
|
66
|
+
const runtime = workspaceRuntime.list()
|
|
67
|
+
const liveByPath = new Map()
|
|
68
|
+
|
|
69
|
+
for (const group of runtime.workspaces || []) {
|
|
70
|
+
if (group.root !== "workspaces") continue
|
|
71
|
+
liveByPath.set(normalizePathKey(group.cwd), group)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const draftByPath = new Map()
|
|
75
|
+
const pendingDrafts = drafts ? await drafts.listPending({}).catch(() => []) : []
|
|
76
|
+
for (const draft of pendingDrafts) {
|
|
77
|
+
if (!draft.cwd) continue
|
|
78
|
+
const key = normalizePathKey(draft.cwd)
|
|
79
|
+
const list = draftByPath.get(key) || []
|
|
80
|
+
list.push(draft)
|
|
81
|
+
draftByPath.set(key, list)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const folders = entries.filter((entry) => entry.isDirectory())
|
|
85
|
+
const items = []
|
|
86
|
+
|
|
87
|
+
for (const entry of folders) {
|
|
88
|
+
const cwd = path.join(root, entry.name)
|
|
89
|
+
const stats = await fs.promises.stat(cwd).catch(() => null)
|
|
90
|
+
const key = normalizePathKey(cwd)
|
|
91
|
+
const live = liveByPath.get(key)
|
|
92
|
+
const shells = live?.shells || []
|
|
93
|
+
const scripts = live?.scripts || []
|
|
94
|
+
const workspaceDrafts = draftByPath.get(key) || []
|
|
95
|
+
const draft = newestDraft(workspaceDrafts)
|
|
96
|
+
const modifiedAtMs = stats?.mtimeMs || 0
|
|
97
|
+
const lastOpenedAtMs = latestTimestamp([
|
|
98
|
+
modifiedAtMs,
|
|
99
|
+
...shells.map((shell) => shell.start_time),
|
|
100
|
+
...workspaceDrafts.map((item) => item.updatedAt),
|
|
101
|
+
])
|
|
102
|
+
const primaryShell = shells.length === 1 ? shells[0] : null
|
|
103
|
+
const primaryScript = scripts.length === 1 ? scripts[0] : null
|
|
104
|
+
const usageCount = shells.length + scripts.length
|
|
105
|
+
|
|
106
|
+
items.push({
|
|
107
|
+
name: entry.name,
|
|
108
|
+
cwd,
|
|
109
|
+
relpath: entry.name,
|
|
110
|
+
modifiedAt: modifiedAtMs ? new Date(modifiedAtMs).toISOString() : null,
|
|
111
|
+
modifiedAtMs,
|
|
112
|
+
lastOpenedAt: lastOpenedAtMs ? new Date(lastOpenedAtMs).toISOString() : null,
|
|
113
|
+
lastOpenedAtMs,
|
|
114
|
+
usageCount,
|
|
115
|
+
running: shells.length > 0 || scripts.length > 0,
|
|
116
|
+
counts: {
|
|
117
|
+
shells: shells.length,
|
|
118
|
+
scripts: scripts.length,
|
|
119
|
+
drafts: workspaceDrafts.length,
|
|
120
|
+
},
|
|
121
|
+
shells,
|
|
122
|
+
scripts,
|
|
123
|
+
draft,
|
|
124
|
+
draftReady: Boolean(draft),
|
|
125
|
+
primaryUrl: primaryScript?.url || primaryShell?.url || null,
|
|
126
|
+
launchUrl: toRoutePath(cwd),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const running = sortWorkspaces(items.filter((item) => item.running), sort)
|
|
131
|
+
const offline = sortWorkspaces(items.filter((item) => !item.running), sort)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
root,
|
|
135
|
+
sort,
|
|
136
|
+
running,
|
|
137
|
+
offline,
|
|
138
|
+
items: [...running, ...offline],
|
|
139
|
+
counts: {
|
|
140
|
+
total: items.length,
|
|
141
|
+
running: running.length,
|
|
142
|
+
offline: offline.length,
|
|
143
|
+
drafts: items.filter((item) => item.draftReady).length,
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { list, normalizeSortMode }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { createWorkspaceCatalogService, normalizeSortMode }
|