sh3-core 0.24.0 → 0.25.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/dist/BrandSlot.svelte +62 -3
- package/dist/BrandSlot.test.js +52 -0
- package/dist/apps/types.d.ts +8 -0
- package/dist/artifact.d.ts +7 -0
- package/dist/build.d.ts +8 -0
- package/dist/build.js +17 -7
- package/dist/build.test.js +27 -1
- package/dist/layout/store.svelte.js +1 -1
- package/dist/overlays/presets.d.ts +17 -2
- package/dist/overlays/presets.js +28 -2
- package/dist/overlays/presets.test.js +29 -0
- package/dist/platform/localSidecar.d.ts +7 -0
- package/dist/platform/localSidecar.js +24 -0
- package/dist/platform/localSidecar.test.d.ts +1 -0
- package/dist/platform/localSidecar.test.js +39 -0
- package/dist/registry/installer.js +50 -10
- package/dist/registry/installer.test.d.ts +1 -0
- package/dist/registry/installer.test.js +146 -0
- package/dist/registry/types.d.ts +19 -0
- package/dist/runtime/runVerb.test.js +87 -0
- package/dist/sh3core-shard/folderActions.d.ts +15 -0
- package/dist/sh3core-shard/folderActions.js +109 -0
- package/dist/sh3core-shard/folderActions.test.d.ts +1 -0
- package/dist/sh3core-shard/folderActions.test.js +43 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -0
- package/dist/shards/lifecycle.svelte.d.ts +8 -0
- package/dist/shards/lifecycle.svelte.js +17 -0
- package/dist/shell-shard/verbs/xfer.js +66 -4
- package/dist/shell-shard/verbs/xfer.test.js +74 -0
- package/dist/verbs/types.d.ts +49 -12
- package/dist/verbs/types.test.d.ts +1 -0
- package/dist/verbs/types.test.js +43 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
import { resetFramework } from '../__test__/reset';
|
|
4
|
+
import { makeApp, makeAppManifest, makeShard, makeShardManifest } from '../__test__/fixtures';
|
|
5
|
+
import { installPackage, loadInstalledPackages } from './installer';
|
|
6
|
+
import { savePackage } from './storage';
|
|
7
|
+
import { registeredApps } from '../apps/registry.svelte';
|
|
8
|
+
import { registeredShards } from '../shards/lifecycle.svelte';
|
|
9
|
+
// Mock the network for loadInstalledPackages tests.
|
|
10
|
+
vi.mock('../env/client', () => ({
|
|
11
|
+
fetchServerPackages: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
import { fetchServerPackages } from '../env/client';
|
|
14
|
+
async function wipeIndexedDB() {
|
|
15
|
+
await new Promise((resolve) => {
|
|
16
|
+
const req = indexedDB.deleteDatabase('sh3-packages');
|
|
17
|
+
req.onsuccess = () => resolve();
|
|
18
|
+
req.onerror = () => resolve();
|
|
19
|
+
req.onblocked = () => resolve();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function makeLoaded(parts = {}) {
|
|
23
|
+
var _a, _b;
|
|
24
|
+
return {
|
|
25
|
+
shards: ((_a = parts.shards) !== null && _a !== void 0 ? _a : []).map((id) => makeShard({ manifest: makeShardManifest({ id }) })),
|
|
26
|
+
apps: ((_b = parts.apps) !== null && _b !== void 0 ? _b : []).map((id) => makeApp({ manifest: makeAppManifest({ id }) })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function meta(id, type, version) {
|
|
30
|
+
return { id, type, version, sourceRegistry: '', contractVersion: '1' };
|
|
31
|
+
}
|
|
32
|
+
function installedRecord(id, type, version) {
|
|
33
|
+
return {
|
|
34
|
+
id,
|
|
35
|
+
type,
|
|
36
|
+
version,
|
|
37
|
+
sourceRegistry: '',
|
|
38
|
+
contractVersion: '1',
|
|
39
|
+
installedAt: '2026-01-01T00:00:00.000Z',
|
|
40
|
+
permissions: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Bug A — installPackage must drop apps/shards the new bundle no longer ships
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('installPackage — diff-based unregistration (Bug A)', () => {
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
resetFramework();
|
|
49
|
+
await wipeIndexedDB();
|
|
50
|
+
});
|
|
51
|
+
it('unregisters apps from the previous bundle that the new bundle no longer ships', async () => {
|
|
52
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
53
|
+
loaded: makeLoaded({ apps: ['bar', 'baz'] }),
|
|
54
|
+
});
|
|
55
|
+
expect(registeredApps.has('bar')).toBe(true);
|
|
56
|
+
expect(registeredApps.has('baz')).toBe(true);
|
|
57
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
58
|
+
loaded: makeLoaded({ apps: ['bar'] }),
|
|
59
|
+
});
|
|
60
|
+
expect(registeredApps.has('bar')).toBe(true);
|
|
61
|
+
expect(registeredApps.has('baz')).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
it('unregisters shards from the previous bundle that the new bundle no longer ships', async () => {
|
|
64
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
65
|
+
loaded: makeLoaded({ shards: ['shard-a', 'shard-b'] }),
|
|
66
|
+
});
|
|
67
|
+
expect(registeredShards.has('shard-a')).toBe(true);
|
|
68
|
+
expect(registeredShards.has('shard-b')).toBe(true);
|
|
69
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
70
|
+
loaded: makeLoaded({ shards: ['shard-a'] }),
|
|
71
|
+
});
|
|
72
|
+
expect(registeredShards.has('shard-a')).toBe(true);
|
|
73
|
+
expect(registeredShards.has('shard-b')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('preserves entries present in both old and new bundles', async () => {
|
|
76
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.0.0'), {
|
|
77
|
+
loaded: makeLoaded({ shards: ['s1'], apps: ['a1'] }),
|
|
78
|
+
});
|
|
79
|
+
await installPackage(new ArrayBuffer(0), meta('combo-foo', 'combo', '1.1.0'), {
|
|
80
|
+
loaded: makeLoaded({ shards: ['s1'], apps: ['a1'] }),
|
|
81
|
+
});
|
|
82
|
+
expect(registeredShards.has('s1')).toBe(true);
|
|
83
|
+
expect(registeredApps.has('a1')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('does not touch unrelated packages on first install of a new package', async () => {
|
|
86
|
+
await installPackage(new ArrayBuffer(0), meta('pkg-other', 'app', '1.0.0'), {
|
|
87
|
+
loaded: makeLoaded({ apps: ['other-app'] }),
|
|
88
|
+
});
|
|
89
|
+
await installPackage(new ArrayBuffer(0), meta('pkg-new', 'app', '1.0.0'), {
|
|
90
|
+
loaded: makeLoaded({ apps: ['new-app'] }),
|
|
91
|
+
});
|
|
92
|
+
expect(registeredApps.has('other-app')).toBe(true);
|
|
93
|
+
expect(registeredApps.has('new-app')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Bug B — loadInstalledPackages must refetch when local version != server
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
describe('loadInstalledPackages — version reconciliation (Bug B)', () => {
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
resetFramework();
|
|
102
|
+
await wipeIndexedDB();
|
|
103
|
+
vi.resetAllMocks();
|
|
104
|
+
// Global fetch — used by _fetchAndCacheFromServer.
|
|
105
|
+
globalThis.fetch = vi.fn();
|
|
106
|
+
// Silence expected warnings: empty-byte bundles can't be dynamic-imported
|
|
107
|
+
// in node, so loadBundleModule throws and the installer warns. The
|
|
108
|
+
// routing decision under test happens before that, so the warnings are
|
|
109
|
+
// noise we don't want polluting test output.
|
|
110
|
+
vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
111
|
+
});
|
|
112
|
+
it('refetches from server when local cached version differs from server version', async () => {
|
|
113
|
+
await savePackage('foo', new ArrayBuffer(0), installedRecord('foo', 'app', '1.0.0'));
|
|
114
|
+
fetchServerPackages.mockResolvedValue([
|
|
115
|
+
{
|
|
116
|
+
id: 'foo',
|
|
117
|
+
type: 'app',
|
|
118
|
+
version: '1.1.0',
|
|
119
|
+
bundleUrl: 'http://server.test/packages/foo/client.js',
|
|
120
|
+
sourceRegistry: '',
|
|
121
|
+
contractVersion: '1',
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
globalThis.fetch.mockResolvedValue({
|
|
125
|
+
ok: true,
|
|
126
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
127
|
+
});
|
|
128
|
+
await loadInstalledPackages();
|
|
129
|
+
expect(globalThis.fetch).toHaveBeenCalledWith('http://server.test/packages/foo/client.js');
|
|
130
|
+
});
|
|
131
|
+
it('uses local cache when local version matches server version', async () => {
|
|
132
|
+
await savePackage('foo', new ArrayBuffer(0), installedRecord('foo', 'app', '1.0.0'));
|
|
133
|
+
fetchServerPackages.mockResolvedValue([
|
|
134
|
+
{
|
|
135
|
+
id: 'foo',
|
|
136
|
+
type: 'app',
|
|
137
|
+
version: '1.0.0',
|
|
138
|
+
bundleUrl: 'http://server.test/packages/foo/client.js',
|
|
139
|
+
sourceRegistry: '',
|
|
140
|
+
contractVersion: '1',
|
|
141
|
+
},
|
|
142
|
+
]);
|
|
143
|
+
await loadInstalledPackages();
|
|
144
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
package/dist/registry/types.d.ts
CHANGED
|
@@ -183,6 +183,25 @@ export interface InstalledPackage {
|
|
|
183
183
|
* must treat a missing value as `[]`.
|
|
184
184
|
*/
|
|
185
185
|
permissions: string[];
|
|
186
|
+
/**
|
|
187
|
+
* Shard ids this package contributed at install time. Populated from
|
|
188
|
+
* `LoadedBundle.shards[].manifest.id`. Used at reinstall to diff against
|
|
189
|
+
* the new bundle and unregister shards the new version no longer ships.
|
|
190
|
+
*
|
|
191
|
+
* Optional for backwards compatibility with records written before this
|
|
192
|
+
* field existed. Treat a missing value as "unknown" — diffing is skipped
|
|
193
|
+
* for that record and the field is populated on the next install.
|
|
194
|
+
*/
|
|
195
|
+
contributedShards?: string[];
|
|
196
|
+
/**
|
|
197
|
+
* App ids this package contributed at install time. Populated from
|
|
198
|
+
* `LoadedBundle.apps[].manifest.id`. Same diff-and-unregister role as
|
|
199
|
+
* `contributedShards`.
|
|
200
|
+
*
|
|
201
|
+
* Optional for backwards compatibility with records written before this
|
|
202
|
+
* field existed.
|
|
203
|
+
*/
|
|
204
|
+
contributedApps?: string[];
|
|
186
205
|
}
|
|
187
206
|
/**
|
|
188
207
|
* Result of an install operation.
|
|
@@ -175,4 +175,91 @@ describe('runVerbProgrammatic', () => {
|
|
|
175
175
|
await runVerbProgrammatic('docs-probe-2', 'docs-probe-2:peek', []);
|
|
176
176
|
expect(seenDocs).toBeUndefined();
|
|
177
177
|
});
|
|
178
|
+
it('surfaces the verb return value as result', async () => {
|
|
179
|
+
registerShard({
|
|
180
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
181
|
+
register(ctx) {
|
|
182
|
+
ctx.registerVerb({
|
|
183
|
+
name: 'returnObj',
|
|
184
|
+
summary: 'returns an object',
|
|
185
|
+
programmatic: true,
|
|
186
|
+
async run() {
|
|
187
|
+
return { answer: 'ok', count: 3 };
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
await activateShard('tester');
|
|
193
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnObj', []);
|
|
194
|
+
expect(out.result).toEqual({ answer: 'ok', count: 3 });
|
|
195
|
+
});
|
|
196
|
+
it('surfaces a primitive verb return value as result', async () => {
|
|
197
|
+
registerShard({
|
|
198
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
199
|
+
register(ctx) {
|
|
200
|
+
ctx.registerVerb({
|
|
201
|
+
name: 'returnNumber',
|
|
202
|
+
summary: 'returns 42',
|
|
203
|
+
programmatic: true,
|
|
204
|
+
schema: {
|
|
205
|
+
input: { type: 'object' },
|
|
206
|
+
output: { type: 'integer' },
|
|
207
|
+
},
|
|
208
|
+
async run() {
|
|
209
|
+
return 42;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
await activateShard('tester');
|
|
215
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnNumber', []);
|
|
216
|
+
expect(out.result).toBe(42);
|
|
217
|
+
});
|
|
218
|
+
it('surfaces undefined as result for a verb that returns nothing', async () => {
|
|
219
|
+
registerShard({
|
|
220
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
221
|
+
register(ctx) {
|
|
222
|
+
ctx.registerVerb({
|
|
223
|
+
name: 'returnVoid',
|
|
224
|
+
summary: 'returns nothing',
|
|
225
|
+
programmatic: true,
|
|
226
|
+
async run() {
|
|
227
|
+
// no return
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
await activateShard('tester');
|
|
233
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnVoid', []);
|
|
234
|
+
expect(out.result).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
it('round-trips an sh3-document handle through result', async () => {
|
|
237
|
+
registerShard({
|
|
238
|
+
manifest: { id: 'tester', label: 'T', version: '0.0.0', views: [] },
|
|
239
|
+
register(ctx) {
|
|
240
|
+
ctx.registerVerb({
|
|
241
|
+
name: 'returnDoc',
|
|
242
|
+
summary: 'returns a document handle',
|
|
243
|
+
programmatic: true,
|
|
244
|
+
schema: {
|
|
245
|
+
input: { type: 'object' },
|
|
246
|
+
output: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
format: 'sh3-document',
|
|
249
|
+
properties: {
|
|
250
|
+
shardId: { type: 'string' },
|
|
251
|
+
path: { type: 'string' },
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
async run() {
|
|
256
|
+
return { shardId: 'notes', path: 'inbox/today.md' };
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
await activateShard('tester');
|
|
262
|
+
const out = await runVerbProgrammatic('tester', 'tester:returnDoc', []);
|
|
263
|
+
expect(out.result).toEqual({ shardId: 'notes', path: 'inbox/today.md' });
|
|
264
|
+
});
|
|
178
265
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ShardContext } from '../shards/types';
|
|
2
|
+
/** Build the docs path for a user-or-project tenant id. Pure. */
|
|
3
|
+
export declare function buildDocumentsPath(root: string, tenantId: string, join: (...parts: string[]) => string): string;
|
|
4
|
+
export declare function disabledForApp(g: {
|
|
5
|
+
isLocal: boolean;
|
|
6
|
+
}): boolean;
|
|
7
|
+
export declare function disabledForUser(g: {
|
|
8
|
+
isLocal: boolean;
|
|
9
|
+
userId: string | null;
|
|
10
|
+
}): boolean;
|
|
11
|
+
export declare function disabledForProject(g: {
|
|
12
|
+
isLocal: boolean;
|
|
13
|
+
projectId: string | null;
|
|
14
|
+
}): boolean;
|
|
15
|
+
export declare function registerFolderActions(ctx: ShardContext): () => void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Palette-only "Open X folder" actions for the Tauri desktop sidecar.
|
|
3
|
+
* All three actions are registered unconditionally; visibility is gated
|
|
4
|
+
* by per-frame `disabled` predicates so changes in project scope or auth
|
|
5
|
+
* state take effect without re-registration.
|
|
6
|
+
*
|
|
7
|
+
* Targets:
|
|
8
|
+
* sh3.openAppDataFolder → appDataDir()
|
|
9
|
+
* sh3.openUserDocumentsFolder → appDataDir()/server/docs/<userId>
|
|
10
|
+
* sh3.openProjectDocumentsFolder → appDataDir()/server/docs/<activeProjectId>
|
|
11
|
+
*
|
|
12
|
+
* The `server/docs/<tenant>` layout matches sh3-server's doc-store on
|
|
13
|
+
* disk (see packages/sh3-server/src/doc-store/store.ts). The Tauri
|
|
14
|
+
* sidecar passes `appDataDir()/server` as sh3-server's --data arg.
|
|
15
|
+
*
|
|
16
|
+
* The runner uses dynamic `import()` for `@tauri-apps/api/path` and
|
|
17
|
+
* `@tauri-apps/plugin-opener` so Vite code-splits the Tauri deps into
|
|
18
|
+
* a separate chunk that only loads when the action runs. Mirrors the
|
|
19
|
+
* pattern used by `platform/index.ts` and `sh3Api/window.ts`.
|
|
20
|
+
*/
|
|
21
|
+
import { isLocalTauriDesktop } from '../platform/localSidecar';
|
|
22
|
+
import { getUser } from '../auth/auth.svelte';
|
|
23
|
+
import { sessionState } from '../projects/session-state.svelte';
|
|
24
|
+
import { toastManager } from '../overlays/toast';
|
|
25
|
+
/** Build the docs path for a user-or-project tenant id. Pure. */
|
|
26
|
+
export function buildDocumentsPath(root, tenantId, join) {
|
|
27
|
+
return join(root, 'server', 'docs', tenantId);
|
|
28
|
+
}
|
|
29
|
+
export function disabledForApp(g) {
|
|
30
|
+
return !g.isLocal;
|
|
31
|
+
}
|
|
32
|
+
export function disabledForUser(g) {
|
|
33
|
+
return !g.isLocal || g.userId == null;
|
|
34
|
+
}
|
|
35
|
+
export function disabledForProject(g) {
|
|
36
|
+
return !g.isLocal || g.projectId == null;
|
|
37
|
+
}
|
|
38
|
+
async function openFolder(kind) {
|
|
39
|
+
try {
|
|
40
|
+
const [{ appDataDir, join }, { openPath }] = await Promise.all([
|
|
41
|
+
import('@tauri-apps/api/path'),
|
|
42
|
+
import('@tauri-apps/plugin-opener'),
|
|
43
|
+
]);
|
|
44
|
+
const root = await appDataDir();
|
|
45
|
+
let target = root;
|
|
46
|
+
if (kind === 'user') {
|
|
47
|
+
const u = getUser();
|
|
48
|
+
if (!u)
|
|
49
|
+
return;
|
|
50
|
+
target = await join(root, 'server', 'docs', u.id);
|
|
51
|
+
}
|
|
52
|
+
else if (kind === 'project') {
|
|
53
|
+
const id = sessionState.activeProjectId;
|
|
54
|
+
if (!id)
|
|
55
|
+
return;
|
|
56
|
+
target = await join(root, 'server', 'docs', id);
|
|
57
|
+
}
|
|
58
|
+
await openPath(target);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
62
|
+
toastManager.notify(`Couldn't open folder: ${msg}`, { level: 'error', duration: 4000 });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function registerFolderActions(ctx) {
|
|
66
|
+
const actions = [
|
|
67
|
+
{
|
|
68
|
+
id: 'sh3.openAppDataFolder',
|
|
69
|
+
label: 'Open SH3 data folder',
|
|
70
|
+
scope: ['home', 'app'],
|
|
71
|
+
contextItem: false,
|
|
72
|
+
paletteItem: true,
|
|
73
|
+
group: 'folders',
|
|
74
|
+
disabled: () => disabledForApp({ isLocal: isLocalTauriDesktop() }),
|
|
75
|
+
run: (_ctx) => openFolder('app'),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'sh3.openUserDocumentsFolder',
|
|
79
|
+
label: 'Open user documents folder',
|
|
80
|
+
scope: ['home', 'app'],
|
|
81
|
+
contextItem: false,
|
|
82
|
+
paletteItem: true,
|
|
83
|
+
group: 'folders',
|
|
84
|
+
disabled: () => {
|
|
85
|
+
var _a, _b;
|
|
86
|
+
return disabledForUser({
|
|
87
|
+
isLocal: isLocalTauriDesktop(),
|
|
88
|
+
userId: (_b = (_a = getUser()) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
run: (_ctx) => openFolder('user'),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'sh3.openProjectDocumentsFolder',
|
|
95
|
+
label: 'Open project documents folder',
|
|
96
|
+
scope: ['home', 'app'],
|
|
97
|
+
contextItem: false,
|
|
98
|
+
paletteItem: true,
|
|
99
|
+
group: 'folders',
|
|
100
|
+
disabled: () => disabledForProject({
|
|
101
|
+
isLocal: isLocalTauriDesktop(),
|
|
102
|
+
projectId: sessionState.activeProjectId,
|
|
103
|
+
}),
|
|
104
|
+
run: (_ctx) => openFolder('project'),
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
const disposers = actions.map((a) => ctx.actions.register(a));
|
|
108
|
+
return () => disposers.forEach((d) => d());
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { buildDocumentsPath, disabledForApp, disabledForUser, disabledForProject, } from './folderActions';
|
|
4
|
+
const join = (...p) => posix.join(...p);
|
|
5
|
+
describe('buildDocumentsPath', () => {
|
|
6
|
+
it('joins server/docs/<tenantId> under root', () => {
|
|
7
|
+
expect(buildDocumentsPath('/data', 'alice', join)).toBe('/data/server/docs/alice');
|
|
8
|
+
});
|
|
9
|
+
it('uses the supplied join, not posix hard-coding', () => {
|
|
10
|
+
const out = buildDocumentsPath('C:\\data', 'bob', (...p) => p.join('\\'));
|
|
11
|
+
expect(out).toBe('C:\\data\\server\\docs\\bob');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe('disabledForApp', () => {
|
|
15
|
+
it('disabled when not local Tauri desktop', () => {
|
|
16
|
+
expect(disabledForApp({ isLocal: false })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('enabled when local Tauri desktop', () => {
|
|
19
|
+
expect(disabledForApp({ isLocal: true })).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('disabledForUser', () => {
|
|
23
|
+
it('disabled when not local Tauri desktop', () => {
|
|
24
|
+
expect(disabledForUser({ isLocal: false, userId: 'alice' })).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it('disabled when no signed-in user', () => {
|
|
27
|
+
expect(disabledForUser({ isLocal: true, userId: null })).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('enabled when local + user present', () => {
|
|
30
|
+
expect(disabledForUser({ isLocal: true, userId: 'alice' })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('disabledForProject', () => {
|
|
34
|
+
it('disabled when not local Tauri desktop', () => {
|
|
35
|
+
expect(disabledForProject({ isLocal: false, projectId: 'acme' })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('disabled when no active project', () => {
|
|
38
|
+
expect(disabledForProject({ isLocal: true, projectId: null })).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('enabled when local + project active', () => {
|
|
41
|
+
expect(disabledForProject({ isLocal: true, projectId: 'acme' })).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -33,6 +33,7 @@ import { resetActivePresetToDefault } from '../layout/store.svelte';
|
|
|
33
33
|
import { modalManager } from '../overlays/modal';
|
|
34
34
|
import { floatManager } from '../overlays/float';
|
|
35
35
|
import { registerAppActions } from './appActions';
|
|
36
|
+
import { registerFolderActions } from './folderActions';
|
|
36
37
|
import { openPalette } from '../actions/listeners';
|
|
37
38
|
/**
|
|
38
39
|
* Build the palette-only float-maximize toggle action. Targets the topmost
|
|
@@ -122,6 +123,7 @@ export const sh3coreShard = {
|
|
|
122
123
|
ctx.registerView('sh3core:home', factory);
|
|
123
124
|
ctx.registerView('sh3:keys-and-peers', keysFactory);
|
|
124
125
|
registerAppActions(ctx);
|
|
126
|
+
registerFolderActions(ctx);
|
|
125
127
|
// Launcher parent — submenu drill host. No `run` needed: the
|
|
126
128
|
// dispatcher's default behavior opens a sub-palette filtered to
|
|
127
129
|
// `submenuOf === 'sh3.app.launch'`. The single parent replaces the
|
|
@@ -112,3 +112,11 @@ export declare function activateShard(id: string): Promise<void>;
|
|
|
112
112
|
* that explicitly want to verify cleanup paths).
|
|
113
113
|
*/
|
|
114
114
|
export declare function deactivateShard(id: string): void;
|
|
115
|
+
/**
|
|
116
|
+
* Remove a shard from the registry entirely. Deactivates it first if active,
|
|
117
|
+
* then drops it from `registeredShards` and clears any error record.
|
|
118
|
+
*
|
|
119
|
+
* Called by the package installer when a bundle update no longer ships a
|
|
120
|
+
* shard that the previous version contributed.
|
|
121
|
+
*/
|
|
122
|
+
export declare function unregisterShard(id: string): void;
|
|
@@ -607,3 +607,20 @@ export function deactivateShard(id) {
|
|
|
607
607
|
shardEntries.delete(id);
|
|
608
608
|
activeShards.delete(id);
|
|
609
609
|
}
|
|
610
|
+
/**
|
|
611
|
+
* Remove a shard from the registry entirely. Deactivates it first if active,
|
|
612
|
+
* then drops it from `registeredShards` and clears any error record.
|
|
613
|
+
*
|
|
614
|
+
* Called by the package installer when a bundle update no longer ships a
|
|
615
|
+
* shard that the previous version contributed.
|
|
616
|
+
*/
|
|
617
|
+
export function unregisterShard(id) {
|
|
618
|
+
if (!registeredShards.has(id))
|
|
619
|
+
return;
|
|
620
|
+
try {
|
|
621
|
+
deactivateShard(id);
|
|
622
|
+
}
|
|
623
|
+
catch ( /* not active */_a) { /* not active */ }
|
|
624
|
+
registeredShards.delete(id);
|
|
625
|
+
erroredShards.delete(id);
|
|
626
|
+
}
|
|
@@ -1,4 +1,38 @@
|
|
|
1
1
|
import { parseScopePath, resolveScope } from './scope-parse';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Path helpers
|
|
4
|
+
//
|
|
5
|
+
// xfer follows cp-style semantics for the destination:
|
|
6
|
+
// - dst path ending with `/` (or empty) is a directory; the file is placed
|
|
7
|
+
// inside with the source's filename (or, for -R, with the source-relative
|
|
8
|
+
// path rebased under the dst directory).
|
|
9
|
+
// - dst path without trailing slash is treated literally.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function basename(p) {
|
|
12
|
+
const i = p.lastIndexOf('/');
|
|
13
|
+
return i >= 0 ? p.slice(i + 1) : p;
|
|
14
|
+
}
|
|
15
|
+
/** Compose a final dst path from a (possibly-directory) dst dir plus a relative segment. */
|
|
16
|
+
function joinDst(dstDir, relative) {
|
|
17
|
+
if (!dstDir)
|
|
18
|
+
return relative;
|
|
19
|
+
if (dstDir.endsWith('/'))
|
|
20
|
+
return dstDir + relative;
|
|
21
|
+
return `${dstDir}/${relative}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Strip a folder prefix off a doc path, returning the remainder. For the
|
|
25
|
+
* single-file case (doc.path equals prefix), returns the filename. The
|
|
26
|
+
* caller's filter guarantees doc.path is either exactly `prefix` or starts
|
|
27
|
+
* with `prefix + '/'`, so this never produces a stray leading slash.
|
|
28
|
+
*/
|
|
29
|
+
function rebaseFromPrefix(prefix, docPath) {
|
|
30
|
+
if (!prefix)
|
|
31
|
+
return docPath;
|
|
32
|
+
if (docPath === prefix)
|
|
33
|
+
return basename(docPath);
|
|
34
|
+
return docPath.slice(prefix.length + 1); // skip prefix and the separating '/'
|
|
35
|
+
}
|
|
2
36
|
export const xferVerb = {
|
|
3
37
|
name: 'xfer',
|
|
4
38
|
summary: [
|
|
@@ -7,6 +41,7 @@ export const xferVerb = {
|
|
|
7
41
|
' Either side may be @me or @project-<slug>; bare paths resolve to the active scope.',
|
|
8
42
|
' -R recursive (src is a folder prefix)',
|
|
9
43
|
' -C copy only, do not delete source',
|
|
44
|
+
' Trailing `/` on dst means "into directory" (cp-style); without it dst is a literal path.',
|
|
10
45
|
].join('\n'),
|
|
11
46
|
programmatic: true,
|
|
12
47
|
async run(ctx, args) {
|
|
@@ -60,25 +95,52 @@ export const xferVerb = {
|
|
|
60
95
|
ctx.scrollback.push({ kind: 'status', text: 'xfer: path required (use -R for folder recursion)', level: 'error', ts });
|
|
61
96
|
return;
|
|
62
97
|
}
|
|
63
|
-
|
|
98
|
+
// cp-style: trailing slash (or empty path) means "into this directory" —
|
|
99
|
+
// append the source filename so the file lands inside instead of trying
|
|
100
|
+
// to overwrite the directory itself.
|
|
101
|
+
const dstIsDir = !dstParsed.path || dstParsed.path.endsWith('/');
|
|
102
|
+
const dstFinalPath = dstIsDir
|
|
103
|
+
? joinDst(dstParsed.path, basename(srcParsed.path))
|
|
104
|
+
: dstParsed.path;
|
|
105
|
+
if (srcTenant === dstTenant && srcParsed.shardId === dstParsed.shardId && srcParsed.path === dstFinalPath) {
|
|
64
106
|
ctx.scrollback.push({ kind: 'status', text: 'xfer: source and destination are the same', level: 'error', ts });
|
|
65
107
|
return;
|
|
66
108
|
}
|
|
67
|
-
await ctx.sh3.docs.transferBetweenScopes(srcTenant, srcParsed.shardId, srcParsed.path, dstTenant, dstParsed.shardId,
|
|
109
|
+
await ctx.sh3.docs.transferBetweenScopes(srcTenant, srcParsed.shardId, srcParsed.path, dstTenant, dstParsed.shardId, dstFinalPath, moveOpts);
|
|
68
110
|
const verb = copy ? 'copied' : 'moved';
|
|
69
111
|
ctx.scrollback.push({ kind: 'status', text: `xfer: ${verb} ${positional[0]} → ${positional[1]}`, level: 'info', ts });
|
|
70
112
|
return;
|
|
71
113
|
}
|
|
72
114
|
const prefix = srcParsed.path;
|
|
73
115
|
const allDocs = await ctx.sh3.docs.listDocumentsIn(srcTenant);
|
|
74
|
-
|
|
116
|
+
// Folder-boundary filter: when `prefix` is set, a doc matches only if its
|
|
117
|
+
// path equals the prefix (single-file case) or sits inside the prefix
|
|
118
|
+
// folder (`prefix + '/'` ...). Plain `startsWith(prefix)` would also
|
|
119
|
+
// match sibling files whose names happen to share the prefix string
|
|
120
|
+
// (e.g. prefix `notes` matching `notesheet.md`).
|
|
121
|
+
const matching = allDocs.filter((d) => {
|
|
122
|
+
if (d.shardId !== srcParsed.shardId)
|
|
123
|
+
return false;
|
|
124
|
+
if (!prefix)
|
|
125
|
+
return true;
|
|
126
|
+
if (d.path === prefix)
|
|
127
|
+
return true;
|
|
128
|
+
return d.path.startsWith(`${prefix}/`);
|
|
129
|
+
});
|
|
75
130
|
if (matching.length === 0) {
|
|
76
131
|
ctx.scrollback.push({ kind: 'status', text: `xfer: no documents found under ${positional[0]}`, level: 'info', ts });
|
|
77
132
|
return;
|
|
78
133
|
}
|
|
79
134
|
let count = 0;
|
|
80
135
|
for (const doc of matching) {
|
|
81
|
-
|
|
136
|
+
// Per-doc dst: rebase the doc's path relative to the src prefix, then
|
|
137
|
+
// place it under the dst directory. Without this the dst directory is
|
|
138
|
+
// ignored and the file lands at its source path in dst shard, which
|
|
139
|
+
// (a) silently misplaces the file and (b) blows up on mounts where
|
|
140
|
+
// `mounts/<unknown-segment>/...` fails to resolve to a real mount.
|
|
141
|
+
const relative = rebaseFromPrefix(prefix, doc.path);
|
|
142
|
+
const dstFinalPath = joinDst(dstParsed.path, relative);
|
|
143
|
+
await ctx.sh3.docs.transferBetweenScopes(srcTenant, doc.shardId, doc.path, dstTenant, dstParsed.shardId, dstFinalPath, moveOpts);
|
|
82
144
|
count++;
|
|
83
145
|
}
|
|
84
146
|
const verb = copy ? 'copied' : 'moved';
|