slidemind 0.3.20 → 0.4.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-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Per-deck mutation wrapper for the persistent workspace.
|
|
2
|
+
//
|
|
3
|
+
// Every supported writer — browser save, task response, adapter action, sync
|
|
4
|
+
// apply — runs through `runDeckOperation`. It reuses the ONE deck lock
|
|
5
|
+
// (`acquireOwner`, revision.mjs) and adds what the lock alone does not give:
|
|
6
|
+
//
|
|
7
|
+
// - a per-deck in-process queue, so two requests inside the daemon never race
|
|
8
|
+
// for the same deck and re-entry from inside an operation fails loudly
|
|
9
|
+
// instead of deadlocking on its own queue;
|
|
10
|
+
// - a durable operation intent (`state/workspace-operation.json`, atomic
|
|
11
|
+
// temp+rename) written BEFORE the mutation and cleared after it, so a crash
|
|
12
|
+
// mid-commit is found by the next writer as `recovery_required` rather than
|
|
13
|
+
// silently half-applied;
|
|
14
|
+
// - a busy answer (`deck_busy`) for a live agent session lock. A session is
|
|
15
|
+
// never stolen; the browser keeps its buffer and retries.
|
|
16
|
+
//
|
|
17
|
+
// Network work is never done under the lock. A remote save is two operations:
|
|
18
|
+
// one to claim and snapshot, the network call outside, one to apply. The queue
|
|
19
|
+
// keeps the pair ordered against other local writers; the sync record
|
|
20
|
+
// (`workspace-sync.json`, sync lane) keeps the network claim.
|
|
21
|
+
|
|
22
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { acquireOwner } from "../lib/revision.mjs";
|
|
28
|
+
|
|
29
|
+
export const OPERATION_FILE = "workspace-operation.json";
|
|
30
|
+
export const SCHEMA_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
const queues = new Map(); // deck key → tail promise
|
|
33
|
+
const active = new AsyncLocalStorage(); // deck key of the operation this code runs inside
|
|
34
|
+
|
|
35
|
+
const nowIso = () => new Date().toISOString();
|
|
36
|
+
|
|
37
|
+
export function operationPath(workspaceDir) {
|
|
38
|
+
return path.join(workspaceDir, "state", OPERATION_FILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function deckKey(workspaceDir) {
|
|
42
|
+
try {
|
|
43
|
+
return fs.realpathSync(workspaceDir);
|
|
44
|
+
} catch {
|
|
45
|
+
return path.resolve(workspaceDir);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeAtomic(file, data) {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
52
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
53
|
+
fs.renameSync(tmp, file);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function typed(code, message, extra = {}) {
|
|
57
|
+
return Object.assign(new Error(message), { code, ...extra });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The unfinished operation on a deck, if any.
|
|
62
|
+
*
|
|
63
|
+
* `{ intent }` for a readable record, `{ malformed: true, raw }` for a file that
|
|
64
|
+
* is there but not a v1 record (kept on disk — it is recovery evidence, not
|
|
65
|
+
* garbage), `null` when the deck is clean.
|
|
66
|
+
*/
|
|
67
|
+
export function readOperationIntent(workspaceDir) {
|
|
68
|
+
const file = operationPath(workspaceDir);
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = fs.readFileSync(file, "utf8");
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err?.code === "ENOENT") return null;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const intent = JSON.parse(raw);
|
|
78
|
+
if (intent?.schemaVersion === SCHEMA_VERSION && typeof intent.id === "string") return { intent };
|
|
79
|
+
return { malformed: true, raw };
|
|
80
|
+
} catch {
|
|
81
|
+
return { malformed: true, raw };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Finish or refuse an unfinished operation. Runs before every mutation.
|
|
87
|
+
*
|
|
88
|
+
* `recover(intent)` is the caller's chance to complete or roll back what the
|
|
89
|
+
* intent describes (it has the before/after hashes the operation noted). It
|
|
90
|
+
* returns truthy when the deck is consistent again; the intent is then cleared.
|
|
91
|
+
* Without a recoverer, or when it declines, the deck stays as it is and
|
|
92
|
+
* `recovery_required` carries the intent — nobody manufactures a result.
|
|
93
|
+
*/
|
|
94
|
+
export function recoverDeckOperation(workspaceDir, { recover = null } = {}) {
|
|
95
|
+
const pending = readOperationIntent(workspaceDir);
|
|
96
|
+
if (!pending) return { recovered: false, intent: null };
|
|
97
|
+
if (pending.malformed) {
|
|
98
|
+
throw typed(
|
|
99
|
+
"recovery_required",
|
|
100
|
+
`${OPERATION_FILE} is unreadable; inspect it before mutating this deck.`,
|
|
101
|
+
{ intent: null, malformed: true },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const { intent } = pending;
|
|
105
|
+
if (recover && recover(intent)) {
|
|
106
|
+
fs.rmSync(operationPath(workspaceDir), { force: true });
|
|
107
|
+
return { recovered: true, intent };
|
|
108
|
+
}
|
|
109
|
+
throw typed(
|
|
110
|
+
"recovery_required",
|
|
111
|
+
`operation ${intent.id} (${intent.kind}) did not finish; recover it before mutating this deck.`,
|
|
112
|
+
{ intent },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run one mutation on a deck under its lock, serialized with every other
|
|
118
|
+
* operation on that deck in this process.
|
|
119
|
+
*
|
|
120
|
+
* `fn(op)` receives `{ token, generation, intent, note(patch) }`. `token` is
|
|
121
|
+
* the owner token to hand to a CHILD adapter process for this operation only;
|
|
122
|
+
* it never goes to a browser. `note(patch)` merges evidence (before/after
|
|
123
|
+
* hashes, partial results) into the durable intent so recovery can finish or
|
|
124
|
+
* refuse precisely.
|
|
125
|
+
*
|
|
126
|
+
* Options: `operationId` (default a fresh UUID), `kind`, `expected` (hashes the
|
|
127
|
+
* caller verified before asking), `command` (lock label), `owner` — `undefined`
|
|
128
|
+
* inherits `SLIDEMIND_OWNER` (a headless command re-entering its own session),
|
|
129
|
+
* a string re-enters that token, `false` refuses to inherit any token (the
|
|
130
|
+
* daemon: it is nobody's session), `recover` (see recoverDeckOperation).
|
|
131
|
+
*
|
|
132
|
+
* Throws `deck_busy` (live session or another process), `recovery_required`,
|
|
133
|
+
* `operation_reentrant` (fn called runDeckOperation on the same deck — the
|
|
134
|
+
* queue would wait on itself), or whatever `fn` throws — with the intent kept
|
|
135
|
+
* on disk for the next writer to recover.
|
|
136
|
+
*/
|
|
137
|
+
export async function runDeckOperation(workspaceDir, options, fn) {
|
|
138
|
+
if (typeof options === "function") {
|
|
139
|
+
fn = options;
|
|
140
|
+
options = {};
|
|
141
|
+
}
|
|
142
|
+
const { operationId = randomUUID(), kind = "mutation", expected = null, command = "workspace", owner, recover = null } =
|
|
143
|
+
options ?? {};
|
|
144
|
+
const key = deckKey(workspaceDir);
|
|
145
|
+
if (active.getStore() === key) {
|
|
146
|
+
throw typed("operation_reentrant", "an operation may not start another on the same deck; split it or compose the work.");
|
|
147
|
+
}
|
|
148
|
+
const prev = queues.get(key) ?? Promise.resolve();
|
|
149
|
+
const run = prev.catch(() => {}).then(() => active.run(key, () => locked()));
|
|
150
|
+
queues.set(key, run);
|
|
151
|
+
try {
|
|
152
|
+
return await run;
|
|
153
|
+
} finally {
|
|
154
|
+
if (queues.get(key) === run) queues.delete(key);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function locked() {
|
|
158
|
+
const held = takeOwner();
|
|
159
|
+
try {
|
|
160
|
+
recoverDeckOperation(workspaceDir, { recover });
|
|
161
|
+
const file = operationPath(workspaceDir);
|
|
162
|
+
let intent = {
|
|
163
|
+
schemaVersion: SCHEMA_VERSION,
|
|
164
|
+
id: operationId,
|
|
165
|
+
kind,
|
|
166
|
+
expected,
|
|
167
|
+
pid: process.pid,
|
|
168
|
+
startedAt: nowIso(),
|
|
169
|
+
notes: {},
|
|
170
|
+
};
|
|
171
|
+
writeAtomic(file, intent);
|
|
172
|
+
const result = await fn({
|
|
173
|
+
token: held.token,
|
|
174
|
+
generation: held.generation,
|
|
175
|
+
get intent() {
|
|
176
|
+
return intent;
|
|
177
|
+
},
|
|
178
|
+
note(patch) {
|
|
179
|
+
intent = { ...intent, notes: { ...intent.notes, ...patch }, updatedAt: nowIso() };
|
|
180
|
+
writeAtomic(file, intent);
|
|
181
|
+
return intent;
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
fs.rmSync(file, { force: true });
|
|
185
|
+
return result;
|
|
186
|
+
} finally {
|
|
187
|
+
held.release();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function takeOwner() {
|
|
192
|
+
const env = process.env.SLIDEMIND_OWNER;
|
|
193
|
+
if (owner === false) delete process.env.SLIDEMIND_OWNER;
|
|
194
|
+
try {
|
|
195
|
+
return acquireOwner(workspaceDir, { command, owner: typeof owner === "string" ? owner : null });
|
|
196
|
+
} finally {
|
|
197
|
+
if (owner === false && env !== undefined) process.env.SLIDEMIND_OWNER = env;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The OS folder dialog, opened by the daemon on the user's behalf. A browser
|
|
2
|
+
// page can never learn a picked folder's absolute path, so the workspace page
|
|
3
|
+
// asks the daemon, which owns the machine, to show Finder / Explorer / zenity
|
|
4
|
+
// and hand the choice back. Resolves to the absolute path, or null when the
|
|
5
|
+
// user cancelled. Throws `picker_unavailable` when no dialog can be shown
|
|
6
|
+
// (headless Linux without zenity/kdialog, a remote session).
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const DIALOG_TIMEOUT_MS = 10 * 60_000; // the dialog waits for a human
|
|
13
|
+
|
|
14
|
+
const commands = {
|
|
15
|
+
darwin: (title) => ["osascript", ["-e", 'tell application "System Events" to activate', "-e", `POSIX path of (choose folder with prompt "${title}")`]],
|
|
16
|
+
win32: (title) => [
|
|
17
|
+
"powershell",
|
|
18
|
+
[
|
|
19
|
+
"-NoProfile",
|
|
20
|
+
"-STA",
|
|
21
|
+
"-Command",
|
|
22
|
+
`Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = '${title}'; $d.ShowNewFolderButton = $false; if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }`,
|
|
23
|
+
],
|
|
24
|
+
],
|
|
25
|
+
linux: (title) => ["zenity", ["--file-selection", "--directory", `--title=${title}`]],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let inFlight = null; // one dialog at a time; a second request joins the first
|
|
29
|
+
|
|
30
|
+
export function pickFolder({ title = "Choose a deck folder" } = {}) {
|
|
31
|
+
if (inFlight) return inFlight;
|
|
32
|
+
const safeTitle = title.replace(/["'\\]/g, "");
|
|
33
|
+
const [cmd, args] = (commands[process.platform] ?? commands.linux)(safeTitle);
|
|
34
|
+
inFlight = new Promise((resolve, reject) => {
|
|
35
|
+
execFile(cmd, args, { timeout: DIALOG_TIMEOUT_MS, windowsHide: false }, (err, stdout) => {
|
|
36
|
+
const out = String(stdout ?? "").trim();
|
|
37
|
+
if (out) return resolve(out.length > 1 ? out.replace(/[\\/]+$/, "") : out);
|
|
38
|
+
// cancel: osascript exits 1 ("User canceled"), zenity exits 1, PowerShell prints nothing
|
|
39
|
+
if (!err || err.code === 1 || /cancel/i.test(err.message ?? "")) return resolve(null);
|
|
40
|
+
const e = new Error(`no folder dialog could be shown here (${err.code === "ENOENT" ? `${cmd} is not installed` : err.message}); type the path instead`);
|
|
41
|
+
e.code = "picker_unavailable";
|
|
42
|
+
reject(e);
|
|
43
|
+
});
|
|
44
|
+
}).finally(() => (inFlight = null));
|
|
45
|
+
return inFlight;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function launch(cmd, args, { tolerateExit1 = process.platform === "win32", timeout = 0 } = {}) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
execFile(cmd, args, { windowsHide: true, timeout }, (err, _stdout, stderr) => {
|
|
51
|
+
// explorer.exe answers 1 even when it opened the window; a PowerShell that
|
|
52
|
+
// threw answers 1 too, so a caller that must know opts out
|
|
53
|
+
if (err && !(tolerateExit1 && err.code === 1)) {
|
|
54
|
+
return reject(new Error(err.code === "ENOENT" ? `${cmd} is not installed` : String(stderr || "").trim() || err.message));
|
|
55
|
+
}
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Show `dir` in the OS file manager (Finder / Explorer / xdg-open). */
|
|
62
|
+
export function revealFolder(dir) {
|
|
63
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [dir]] : process.platform === "win32" ? ["explorer", [dir]] : ["xdg-open", [dir]];
|
|
64
|
+
return launch(cmd, args).catch((err) => {
|
|
65
|
+
const e = new Error(`could not open the folder here (${err.message})`);
|
|
66
|
+
e.code = "reveal_unavailable";
|
|
67
|
+
throw e;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Open `file` in its desktop app. PowerPoint is named explicitly on macOS —
|
|
73
|
+
* `open` alone hands a .pptx to whatever claims the extension (Keynote on a
|
|
74
|
+
* fresh Mac) — and falls back to the default handler when it is not installed.
|
|
75
|
+
*/
|
|
76
|
+
export async function openFile(file) {
|
|
77
|
+
const tries =
|
|
78
|
+
process.platform === "darwin"
|
|
79
|
+
? [["open", ["-a", "Microsoft PowerPoint", file]], ["open", [file]]]
|
|
80
|
+
: process.platform === "win32"
|
|
81
|
+
? [["explorer", [file]]]
|
|
82
|
+
: [["xdg-open", [file]]];
|
|
83
|
+
let last = null;
|
|
84
|
+
for (const [cmd, args] of tries) {
|
|
85
|
+
try {
|
|
86
|
+
return await launch(cmd, args);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
last = err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const e = new Error(`could not open this file here (${last ? last.message : "no handler"})`);
|
|
92
|
+
e.code = "open_unavailable";
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const powerShellString = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Move `dir` to the OS trash. Recoverable on purpose: the workspace never calls
|
|
100
|
+
* fs.rm on a user's deck, so a wrong click is undone from the Trash. Node has no
|
|
101
|
+
* API for this and we add no dependency.
|
|
102
|
+
*
|
|
103
|
+
* macOS moves the folder into ~/.Trash directly. Asking Finder over AppleScript
|
|
104
|
+
* is the documented way and it is NOT usable here: the first call raises the
|
|
105
|
+
* automation-permission prompt and blocks the daemon until a human answers it
|
|
106
|
+
* (observed: `AppleEvent timed out (-1712)` after two minutes). A rename needs
|
|
107
|
+
* no permission and no prompt. Elsewhere the platform's own trash command runs,
|
|
108
|
+
* under a timeout.
|
|
109
|
+
*
|
|
110
|
+
* The result is VERIFIED — a command that exits 0 without removing the folder is
|
|
111
|
+
* a failure, not a success. Throws `trash_unavailable` when this machine has no
|
|
112
|
+
* trash to move it to.
|
|
113
|
+
*/
|
|
114
|
+
export async function trashFolder(dir) {
|
|
115
|
+
let failure = null;
|
|
116
|
+
try {
|
|
117
|
+
if (process.platform === "darwin") moveToHomeTrash(dir);
|
|
118
|
+
else if (process.platform === "win32") {
|
|
119
|
+
await launch("powershell", ["-NoProfile", "-Command", `$ErrorActionPreference='Stop'; Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(${powerShellString(dir)}, 'OnlyErrorDialogs', 'SendToRecycleBin')`], { tolerateExit1: false, timeout: TRASH_TIMEOUT_MS });
|
|
120
|
+
} else await launch("gio", ["trash", dir], { tolerateExit1: false, timeout: TRASH_TIMEOUT_MS });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
failure = err;
|
|
123
|
+
}
|
|
124
|
+
if (!fs.existsSync(dir)) return dir;
|
|
125
|
+
const e = new Error(`could not move the folder to the Trash here (${failure ? failure.message : "it is still on disk"}); delete it in your file manager instead`);
|
|
126
|
+
e.code = "trash_unavailable";
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TRASH_TIMEOUT_MS = 60_000; // a trash move is local work; a prompt or a hang must not own the daemon
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ~/.Trash/<name>, with " 2", " 3"… when that name is taken, exactly as Finder
|
|
134
|
+
* numbers a collision.
|
|
135
|
+
* ponytail: same-volume only. A deck on an external disk belongs in that
|
|
136
|
+
* volume's /Volumes/X/.Trashes/<uid>; EXDEV is reported instead of copying
|
|
137
|
+
* gigabytes behind the user's back.
|
|
138
|
+
*/
|
|
139
|
+
function moveToHomeTrash(dir) {
|
|
140
|
+
const trash = path.join(os.homedir(), ".Trash");
|
|
141
|
+
fs.mkdirSync(trash, { recursive: true });
|
|
142
|
+
const base = path.basename(dir);
|
|
143
|
+
let target = path.join(trash, base);
|
|
144
|
+
for (let n = 2; fs.existsSync(target); n++) target = path.join(trash, `${base} ${n}`);
|
|
145
|
+
try {
|
|
146
|
+
fs.renameSync(dir, target);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err?.code !== "EXDEV") throw err;
|
|
149
|
+
throw new Error(`${dir} is on another volume than your home folder; move it to the Trash in Finder`);
|
|
150
|
+
}
|
|
151
|
+
return target;
|
|
152
|
+
}
|