sh3-core 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Sh3.svelte CHANGED
@@ -79,19 +79,19 @@
79
79
 
80
80
  const edgePointers = new Set<number>();
81
81
 
82
- function onPointerDown(e: PointerEvent): void {
82
+ const onPointerDown = (e: PointerEvent): void => {
83
83
  const rect = el.getBoundingClientRect();
84
84
  const local = e.clientX - rect.left;
85
85
  if (local >= EDGE_PX && local <= rect.width - EDGE_PX) return;
86
86
  const granted = claim(e.pointerId, { ownerId: 'sh3:edge', axis: 'x', priority: 'edge', depth: 0 });
87
87
  if (granted) edgePointers.add(e.pointerId);
88
- }
88
+ };
89
89
 
90
- function onPointerEnd(e: PointerEvent): void {
90
+ const onPointerEnd = (e: PointerEvent): void => {
91
91
  if (!edgePointers.has(e.pointerId)) return;
92
92
  revoke(e.pointerId, 'sh3:edge');
93
93
  edgePointers.delete(e.pointerId);
94
- }
94
+ };
95
95
 
96
96
  el.addEventListener('pointerdown', onPointerDown);
97
97
  el.addEventListener('pointerup', onPointerEnd);
@@ -50,6 +50,7 @@ export function listActionsFromEntries(entries, state) {
50
50
  ownerShardId: entry.ownerShardId,
51
51
  paletteItem: entry.action.paletteItem !== false,
52
52
  contextItem: entry.action.contextItem !== false,
53
+ aiInvocable: entry.action.aiInvocable,
53
54
  submenu: entry.action.submenu,
54
55
  submenuOf: entry.action.submenuOf,
55
56
  active,
@@ -57,6 +57,19 @@ describe('listActiveFromEntries', () => {
57
57
  expect(out[0].paletteItem).toBe(false);
58
58
  expect(out[0].contextItem).toBe(true); // defaults to true
59
59
  });
60
+ it('propagates aiInvocable from the registered action, preserving undefined', () => {
61
+ const entries = [
62
+ mkEntry({ id: 'opt-out', scope: 'home', aiInvocable: false }),
63
+ mkEntry({ id: 'opt-in', scope: 'home', aiInvocable: true }),
64
+ mkEntry({ id: 'unset', scope: 'home' }),
65
+ ];
66
+ const out = listActiveFromEntries(entries, mkState());
67
+ const byId = Object.fromEntries(out.map((d) => [d.id, d]));
68
+ expect(byId['opt-out'].aiInvocable).toBe(false);
69
+ expect(byId['opt-in'].aiInvocable).toBe(true);
70
+ // `undefined` is significant — consumers filter `=== false`, not falsy.
71
+ expect(byId['unset'].aiInvocable).toBeUndefined();
72
+ });
60
73
  it('dedupes by action id', () => {
61
74
  const entries = [
62
75
  mkEntry({ id: 'dup', scope: 'home' }, 'shard.a'),
@@ -14,6 +14,14 @@ export interface Action {
14
14
  scope: ActionScope;
15
15
  contextItem?: boolean;
16
16
  paletteItem?: boolean;
17
+ /**
18
+ * Opt-out flag for AI tool catalogs. Set `false` to hide this action
19
+ * from LLM-facing surfaces (e.g. `sh3-ai`'s action→tool adapter) — use
20
+ * for palette-only actions that need a UI picker and are meaningless
21
+ * to invoke programmatically. Defaults to `undefined` (catalog
22
+ * inclusion decided by the consumer).
23
+ */
24
+ aiInvocable?: boolean;
17
25
  /**
18
26
  * Optional menu container id. When set and the active app's declared
19
27
  * (or canonical fallback) menu list contains this id, the action
@@ -148,6 +156,8 @@ export interface ActiveActionDescriptor {
148
156
  ownerShardId: string;
149
157
  paletteItem: boolean;
150
158
  contextItem: boolean;
159
+ /** Carried through from the registered action; see `Action.aiInvocable`. */
160
+ aiInvocable?: boolean;
151
161
  /** True when this action is a submenu parent (children opened by drill). */
152
162
  submenu?: true;
153
163
  /** Parent action id when this action is a submenu child. */
@@ -187,6 +197,8 @@ export interface ActionDescriptor {
187
197
  ownerShardId: string;
188
198
  paletteItem: boolean;
189
199
  contextItem: boolean;
200
+ /** Carried through from the registered action; see `Action.aiInvocable`. */
201
+ aiInvocable?: boolean;
190
202
  /** True when this action is a submenu parent (children opened by drill). */
191
203
  submenu?: true;
192
204
  /** Parent action id when this action is a submenu child. */
package/dist/api.d.ts CHANGED
@@ -65,6 +65,8 @@ export type { RunVerbOpts, RunVerbResult } from './runtime';
65
65
  export { registerShellMode } from './shell-shard/registerShellMode';
66
66
  export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
67
67
  export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
68
+ export { CONTEXT_SOURCE_POINT_ID } from './contributions/contextSource';
69
+ export type { ContextSource } from './contributions/contextSource';
68
70
  export type { GestureRegistry, GestureHandle } from './gestures';
69
71
  export type { GestureType, Axis, ClaimPriority, ClaimEntry, PanEvent, ScrollEvent, ButtonEvent, PanOptions, DragOptions, ButtonOptions, ScrollOptions, } from './gestures/types';
70
72
  export { VERSION } from './version';
package/dist/api.js CHANGED
@@ -65,6 +65,8 @@ export { runVerbProgrammatic } from './runtime';
65
65
  // Sh3 mode contributions (external shards extend the sh3 with new modes).
66
66
  export { registerShellMode } from './shell-shard/registerShellMode';
67
67
  export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
68
+ // Context-source contributions (publishers register entries; consumers like sh3-ai pick them up).
69
+ export { CONTEXT_SOURCE_POINT_ID } from './contributions/contextSource';
68
70
  // Package version.
69
71
  export { VERSION } from './version';
70
72
  // Framework shard IDs — shards that are always present (built-in to sh3-core).
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { storeContext } from './storeShard.svelte';
10
10
  import { fetchArchive, buildPackageMeta } from '../../registry/client';
11
- import { readFileFromArchive, readManifestFromArchive } from '../../registry/archive';
11
+ import { readFileFromArchive } from '../../registry/archive';
12
12
  import { installPackage } from '../../registry/installer';
13
13
  import { loadBundleModule, type LoadedBundle } from '../../registry/loader';
14
14
  import { extractBundlePermissions } from '../../registry/permission-descriptions';
@@ -15,7 +15,6 @@
15
15
  resolveMenuContainers,
16
16
  resolveMenuItems,
17
17
  resolveSubmenuItems,
18
- type MenuBarItem,
19
18
  } from '../actions/menuBarModel';
20
19
  import { listActions } from '../actions/registry';
21
20
  import { getLiveDispatcherState } from '../actions/state.svelte';
@@ -54,7 +53,16 @@
54
53
  }
55
54
 
56
55
  // --- derived items for current nav level ---------------------------
57
- const currentItems = $derived.by(() => {
56
+ interface SheetItem {
57
+ id: string;
58
+ label: string;
59
+ isContainer: boolean;
60
+ isSubmenu: boolean;
61
+ shortcut: string | null;
62
+ disabled: boolean;
63
+ }
64
+
65
+ const currentItems = $derived.by<SheetItem[]>(() => {
58
66
  const entries = listActions();
59
67
  const nav = currentNav;
60
68
 
@@ -65,7 +73,10 @@
65
73
  .map((c) => ({
66
74
  id: c.id,
67
75
  label: c.label,
68
- isContainer: true as const,
76
+ isContainer: true,
77
+ isSubmenu: false,
78
+ shortcut: null,
79
+ disabled: false,
69
80
  }));
70
81
  }
71
82
 
@@ -74,8 +85,9 @@
74
85
  return items.map((item) => ({
75
86
  id: item.id,
76
87
  label: item.label,
77
- shortcut: item.shortcut,
88
+ isContainer: false,
78
89
  isSubmenu: item.submenu === true,
90
+ shortcut: item.shortcut,
79
91
  disabled: item.disabled,
80
92
  }));
81
93
  }
@@ -85,14 +97,15 @@
85
97
  return items.map((item) => ({
86
98
  id: item.id,
87
99
  label: item.label,
100
+ isContainer: false,
101
+ isSubmenu: item.submenu === true,
88
102
  shortcut: item.shortcut,
89
103
  disabled: item.disabled,
90
- isSubmenu: item.submenu === true,
91
104
  }));
92
105
  });
93
106
 
94
107
  // --- actions --------------------------------------------------------
95
- function handleTap(entry: { id: string; isContainer?: boolean; isSubmenu?: boolean }) {
108
+ function handleTap(entry: SheetItem) {
96
109
  if (entry.isContainer) {
97
110
  const c = containers.find((x) => x.id === entry.id);
98
111
  if (c) push({ kind: 'container', containerId: c.id, label: c.label });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Contribution point id: shards register `ContextSource` descriptors here.
3
+ * Each registration adds one pickable entry in any consuming UI (e.g. the
4
+ * "SOURCES" section of the AI Edit modal when sh3-ai is installed) and may
5
+ * be picked up by future consumers. Lifecycle is publisher-owned — register
6
+ * when content becomes relevant (app activation, project load, selection),
7
+ * dispose when it stops being relevant.
8
+ */
9
+ export declare const CONTEXT_SOURCE_POINT_ID = "sh3.contextSource";
10
+ /** A single context-source contribution. */
11
+ export interface ContextSource {
12
+ /**
13
+ * Globally unique. Convention: `<shardId>:<slug>`. Used as the picker
14
+ * selection key, so it must be stable across re-renders. Re-registering
15
+ * with an existing id silently replaces — generally dispose the prior
16
+ * registration first when swapping content.
17
+ */
18
+ id: string;
19
+ /** Short display name shown in the picker row and the chip body. */
20
+ label: string;
21
+ /**
22
+ * Tooltip in any consuming UI. Consumers may also surface this to
23
+ * downstream tools (e.g. as the description sh3-ai exposes when
24
+ * chat-side context tools land).
25
+ */
26
+ description?: string;
27
+ /**
28
+ * Drives prompt formatting (when consumed by sh3-ai) and the chip kind tag.
29
+ * - `text` (default): value coerced to string, dumped raw.
30
+ * - `markdown`: value coerced to string, wrapped in fenced ```markdown``` block.
31
+ * - `json`: value `JSON.stringify`-ed with 2-space indent, wrapped in fenced ```json``` block.
32
+ */
33
+ kind?: 'text' | 'markdown' | 'json';
34
+ /**
35
+ * Sub-header under the picker's SOURCES section (e.g. the consuming
36
+ * shard's display name). Entries without a group fall under an "Other"
37
+ * sub-header.
38
+ */
39
+ group?: string;
40
+ /**
41
+ * Lazy fetcher. Called when the user picks the chip (for the expand
42
+ * preview pane) and again at consume time. May be sync or async.
43
+ * Returning null/undefined signals "no content available right now" —
44
+ * entry is silently omitted but the chip remains. Throwing/rejecting
45
+ * surfaces a toast and skips the entry.
46
+ */
47
+ get(): unknown | Promise<unknown>;
48
+ }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Public contract for context-source contributions. Shards register
3
+ * `ContextSource` descriptors at `CONTEXT_SOURCE_POINT_ID` via the
4
+ * standard `ctx.contributions.register` API; consumers (sh3-ai today,
5
+ * potentially inspectors / hover previews / chat-side context tools
6
+ * tomorrow) enumerate them via `ctx.contributions.list`.
7
+ *
8
+ * v1 has a single consumer (sh3-ai). The descriptor shape is hosted
9
+ * here so publisher shards do not need a devDependency on sh3-ai to
10
+ * contribute. Lifecycle is consumer-owned — see the JSDoc on
11
+ * `CONTEXT_SOURCE_POINT_ID` below.
12
+ */
13
+ /**
14
+ * Contribution point id: shards register `ContextSource` descriptors here.
15
+ * Each registration adds one pickable entry in any consuming UI (e.g. the
16
+ * "SOURCES" section of the AI Edit modal when sh3-ai is installed) and may
17
+ * be picked up by future consumers. Lifecycle is publisher-owned — register
18
+ * when content becomes relevant (app activation, project load, selection),
19
+ * dispose when it stops being relevant.
20
+ */
21
+ export const CONTEXT_SOURCE_POINT_ID = 'sh3.contextSource';
@@ -37,13 +37,4 @@ export interface DocumentPickerOptions {
37
37
  * own namespace, so the user can't navigate into a dead-end root. */
38
38
  lockToShard?: boolean;
39
39
  }
40
- /**
41
- * Create a document picker API bound to a document listing function.
42
- * The listFn is derived from the shard's document zone + browse permission
43
- * and baked in at construction time so callers don't pass their own scope.
44
- *
45
- * When an `anchor` element is provided the browser opens as a popup
46
- * (anchored near the element). Without an anchor it opens as a centered
47
- * modal (the expected default for file-browser dialogs).
48
- */
49
40
  export declare function createDocumentPicker(listFn: DocListFn, options?: DocumentPickerOptions): DocumentPickerApi;
@@ -2,15 +2,6 @@ import { sh3 } from '../sh3Runtime.svelte';
2
2
  import DocumentBrowser from '../primitives/widgets/_DocumentBrowser.svelte';
3
3
  const BOX_STYLE = 'max-width: min(800px, 95vw);';
4
4
  const MODAL_OPTS = { dismissOnBackdrop: true, boxStyle: BOX_STYLE };
5
- /**
6
- * Create a document picker API bound to a document listing function.
7
- * The listFn is derived from the shard's document zone + browse permission
8
- * and baked in at construction time so callers don't pass their own scope.
9
- *
10
- * When an `anchor` element is provided the browser opens as a popup
11
- * (anchored near the element). Without an anchor it opens as a centered
12
- * modal (the expected default for file-browser dialogs).
13
- */
14
5
  export function createDocumentPicker(listFn, options = {}) {
15
6
  const { listFolders, handle, readOnlyShard, initialShardId, lockToShard } = options;
16
7
  function openBrowser(browserProps, anchor) {
@@ -1,10 +1,12 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="M extends 'open' | 'save'">
2
2
  import type { CommitOnlyEvents } from './_contract';
3
3
  import { sh3 } from '../../sh3Runtime.svelte';
4
4
  import DocumentBrowser from './_DocumentBrowser.svelte';
5
5
  import type { DocumentMeta } from '../../documents/types';
6
6
  import type { DocEntry, OpenerValue, SaverValue } from './DocumentFilePicker';
7
7
 
8
+ type ValueFor<Mode extends 'open' | 'save'> = Mode extends 'open' ? OpenerValue : SaverValue;
9
+
8
10
  type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
9
11
  type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
10
12
  type HandleFn = {
@@ -17,7 +19,7 @@
17
19
 
18
20
  let {
19
21
  mode,
20
- value = $bindable<OpenerValue | SaverValue>(null),
22
+ value = $bindable<ValueFor<M>>(null as ValueFor<M>),
21
23
  listDocuments,
22
24
  listFolders,
23
25
  handle,
@@ -29,8 +31,8 @@
29
31
  selectable = 'file',
30
32
  onchange,
31
33
  }: {
32
- mode: 'open' | 'save';
33
- value?: OpenerValue | SaverValue;
34
+ mode: M;
35
+ value?: ValueFor<M>;
34
36
  listDocuments: DocListFn;
35
37
  listFolders?: FolderListFn;
36
38
  handle?: HandleFn;
@@ -40,7 +42,7 @@
40
42
  size?: 'sm' | 'md';
41
43
  buttonLabel?: string;
42
44
  selectable?: 'file' | 'folder' | 'both';
43
- } & CommitOnlyEvents<OpenerValue | SaverValue> = $props();
45
+ } & CommitOnlyEvents<ValueFor<M>> = $props();
44
46
 
45
47
  let trigger = $state<HTMLButtonElement | undefined>(undefined);
46
48
  let openFlag = $state(false);
@@ -56,8 +58,8 @@
56
58
  );
57
59
 
58
60
  function handleCommit(result: OpenerValue | SaverValue) {
59
- value = result;
60
- onchange?.(result);
61
+ value = result as ValueFor<M>;
62
+ onchange?.(result as ValueFor<M>);
61
63
  }
62
64
 
63
65
  function onOpenClosed() {
@@ -1,32 +1,49 @@
1
1
  import type { CommitOnlyEvents } from './_contract';
2
2
  import type { DocumentMeta } from '../../documents/types';
3
3
  import type { OpenerValue, SaverValue } from './DocumentFilePicker';
4
- type DocListFn = () => Promise<Array<DocumentMeta & {
5
- shardId: string;
6
- }>>;
7
- type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
8
- type HandleFn = {
9
- mkdir: (shardId: string, path: string) => Promise<void>;
10
- rmdir: (shardId: string, path: string, opts: {
11
- recursive: boolean;
12
- }) => Promise<void>;
13
- renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
- rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
15
- delete: (shardId: string, path: string) => Promise<void>;
4
+ declare function $$render<M extends 'open' | 'save'>(): {
5
+ props: {
6
+ mode: M;
7
+ value?: M extends "open" ? OpenerValue : SaverValue;
8
+ listDocuments: () => Promise<Array<DocumentMeta & {
9
+ shardId: string;
10
+ }>>;
11
+ listFolders?: (shardId: string, prefix: string) => Promise<string[]>;
12
+ handle?: {
13
+ mkdir: (shardId: string, path: string) => Promise<void>;
14
+ rmdir: (shardId: string, path: string, opts: {
15
+ recursive: boolean;
16
+ }) => Promise<void>;
17
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
18
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
19
+ delete: (shardId: string, path: string) => Promise<void>;
20
+ };
21
+ readOnlyShard?: (shardId: string) => boolean;
22
+ disabled?: boolean;
23
+ invalid?: boolean;
24
+ size?: "sm" | "md";
25
+ buttonLabel?: string;
26
+ selectable?: "file" | "folder" | "both";
27
+ } & CommitOnlyEvents<M extends "open" ? OpenerValue : SaverValue>;
28
+ exports: {};
29
+ bindings: "value";
30
+ slots: {};
31
+ events: {};
16
32
  };
17
- type $$ComponentProps = {
18
- mode: 'open' | 'save';
19
- value?: OpenerValue | SaverValue;
20
- listDocuments: DocListFn;
21
- listFolders?: FolderListFn;
22
- handle?: HandleFn;
23
- readOnlyShard?: (shardId: string) => boolean;
24
- disabled?: boolean;
25
- invalid?: boolean;
26
- size?: 'sm' | 'md';
27
- buttonLabel?: string;
28
- selectable?: 'file' | 'folder' | 'both';
29
- } & CommitOnlyEvents<OpenerValue | SaverValue>;
30
- declare const DocumentFilePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
31
- type DocumentFilePicker = ReturnType<typeof DocumentFilePicker>;
33
+ declare class __sveltets_Render<M extends 'open' | 'save'> {
34
+ props(): ReturnType<typeof $$render<M>>['props'];
35
+ events(): ReturnType<typeof $$render<M>>['events'];
36
+ slots(): ReturnType<typeof $$render<M>>['slots'];
37
+ bindings(): "value";
38
+ exports(): {};
39
+ }
40
+ interface $$IsomorphicComponent {
41
+ new <M extends 'open' | 'save'>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<M>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<M>['props']>, ReturnType<__sveltets_Render<M>['events']>, ReturnType<__sveltets_Render<M>['slots']>> & {
42
+ $$bindings?: ReturnType<__sveltets_Render<M>['bindings']>;
43
+ } & ReturnType<__sveltets_Render<M>['exports']>;
44
+ <M extends 'open' | 'save'>(internal: unknown, props: ReturnType<__sveltets_Render<M>['props']> & {}): ReturnType<__sveltets_Render<M>['exports']>;
45
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
46
+ }
47
+ declare const DocumentFilePicker: $$IsomorphicComponent;
48
+ type DocumentFilePicker<M extends 'open' | 'save'> = InstanceType<typeof DocumentFilePicker<M>>;
32
49
  export default DocumentFilePicker;
@@ -59,7 +59,6 @@
59
59
  let selected = $state<Selected>(null);
60
60
  let filename = $state(untrack(() => suggestedName));
61
61
  let activeIdx = $state(0);
62
- let listEl = $state<HTMLElement | undefined>(undefined);
63
62
 
64
63
  // Folder state loaded via listFolders
65
64
  let folders = $state<string[]>([]);
@@ -459,11 +458,12 @@
459
458
  </div>
460
459
  {/if}
461
460
 
462
- <div class="sh3-doc-browser__list" bind:this={listEl}>
461
+ <div class="sh3-doc-browser__list">
463
462
  {#if confirmDelete}
464
- {@const childCount = confirmDelete.kind === 'folder'
463
+ {@const folderPath = confirmDelete.kind === 'folder' ? confirmDelete.fullPath : null}
464
+ {@const childCount = folderPath
465
465
  ? docs.filter((d) =>
466
- d.shardId === shardId && d.path.startsWith(confirmDelete!.fullPath + '/'),
466
+ d.shardId === shardId && d.path.startsWith(folderPath + '/'),
467
467
  ).length
468
468
  : 0}
469
469
  <div class="sh3-doc-browser__confirm-overlay">
@@ -18,7 +18,6 @@
18
18
  import { makeSelectionApi } from '../actions/selection.svelte';
19
19
  import { getAppearance } from '../app-appearance';
20
20
  import iconsUrl from '../assets/icons.svg';
21
- import { manifest } from '../shell-shard/manifest';
22
21
 
23
22
  const homeSelection = makeSelectionApi('__sh3core__');
24
23
 
@@ -14,6 +14,7 @@
14
14
  * defensive pattern as `platform/index.ts`. Vite code-splits it
15
15
  * into a Tauri-only chunk that never loads in web builds.
16
16
  */
17
+ import { getEnvServerUrl } from '../env/serverUrl';
17
18
  import { getAuthToken } from './authToken';
18
19
  let tauriFetch = null;
19
20
  let tauriProbed = false;
@@ -43,8 +44,25 @@ async function getTauriFetch() {
43
44
  }
44
45
  return tauriFetch;
45
46
  }
47
+ // Resolve relative paths against the configured server URL so callers can
48
+ // use bare `/api/...` paths and have them hit the configured sh3-server
49
+ // regardless of the webview's origin. Mirrors ctx.fetch's resolveUrl —
50
+ // absolute URLs pass through, relatives get prefixed when a serverUrl is
51
+ // set. When no serverUrl is configured (e.g. early bootstrap, web builds
52
+ // hosted same-origin), the path is left untouched so `fetch` falls back
53
+ // to `window.location.origin` as before.
54
+ function resolveApiUrl(url) {
55
+ if (url.startsWith('http://') || url.startsWith('https://'))
56
+ return url;
57
+ const base = getEnvServerUrl();
58
+ if (!base)
59
+ return url;
60
+ const sep = url.startsWith('/') ? '' : '/';
61
+ return `${base}${sep}${url}`;
62
+ }
46
63
  export function apiFetch(url, init) {
47
64
  var _a;
65
+ const resolved = resolveApiUrl(url);
48
66
  // Inject Authorization: Bearer <session-token> if a session is active
49
67
  // and the caller didn't already supply one. Cookies don't survive the
50
68
  // cross-origin hop (SameSite=Lax + plugin-http has no cookie store),
@@ -62,11 +80,11 @@ export function apiFetch(url, init) {
62
80
  // call-timing assertions in tests still hold and web builds skip the
63
81
  // dynamic-import probe entirely.
64
82
  if (!inTauriRuntime()) {
65
- return fetch(url, Object.assign({ credentials: 'include' }, finalInit));
83
+ return fetch(resolved, Object.assign({ credentials: 'include' }, finalInit));
66
84
  }
67
85
  return getTauriFetch().then((tf) => {
68
86
  if (tf)
69
- return tf(url, finalInit);
70
- return fetch(url, Object.assign({ credentials: 'include' }, finalInit));
87
+ return tf(resolved, finalInit);
88
+ return fetch(resolved, Object.assign({ credentials: 'include' }, finalInit));
71
89
  });
72
90
  }
@@ -34,4 +34,67 @@ describe('apiFetch', () => {
34
34
  const [, init] = calls[0];
35
35
  expect(init.credentials).toBe('omit');
36
36
  });
37
+ it('resolves relative paths against the configured serverUrl', async () => {
38
+ const calls = [];
39
+ globalThis.fetch = vi.fn(async (input) => {
40
+ calls.push(String(input));
41
+ return new Response('ok');
42
+ });
43
+ const { __setEnvServerUrl } = await import('../env/serverUrl');
44
+ __setEnvServerUrl('https://remote.example.com');
45
+ try {
46
+ const { apiFetch } = await import('./apiFetch');
47
+ await apiFetch('/api/admin/users');
48
+ expect(calls[0]).toBe('https://remote.example.com/api/admin/users');
49
+ }
50
+ finally {
51
+ __setEnvServerUrl('');
52
+ }
53
+ });
54
+ it('prepends a slash to bare relative paths when serverUrl is set', async () => {
55
+ const calls = [];
56
+ globalThis.fetch = vi.fn(async (input) => {
57
+ calls.push(String(input));
58
+ return new Response('ok');
59
+ });
60
+ const { __setEnvServerUrl } = await import('../env/serverUrl');
61
+ __setEnvServerUrl('https://remote.example.com');
62
+ try {
63
+ const { apiFetch } = await import('./apiFetch');
64
+ await apiFetch('api/admin/users');
65
+ expect(calls[0]).toBe('https://remote.example.com/api/admin/users');
66
+ }
67
+ finally {
68
+ __setEnvServerUrl('');
69
+ }
70
+ });
71
+ it('passes absolute URLs through unchanged regardless of serverUrl', async () => {
72
+ const calls = [];
73
+ globalThis.fetch = vi.fn(async (input) => {
74
+ calls.push(String(input));
75
+ return new Response('ok');
76
+ });
77
+ const { __setEnvServerUrl } = await import('../env/serverUrl');
78
+ __setEnvServerUrl('https://remote.example.com');
79
+ try {
80
+ const { apiFetch } = await import('./apiFetch');
81
+ await apiFetch('https://other.example.com/api/bar');
82
+ expect(calls[0]).toBe('https://other.example.com/api/bar');
83
+ }
84
+ finally {
85
+ __setEnvServerUrl('');
86
+ }
87
+ });
88
+ it('leaves relative paths alone when no serverUrl is configured', async () => {
89
+ const calls = [];
90
+ globalThis.fetch = vi.fn(async (input) => {
91
+ calls.push(String(input));
92
+ return new Response('ok');
93
+ });
94
+ const { __setEnvServerUrl } = await import('../env/serverUrl');
95
+ __setEnvServerUrl('');
96
+ const { apiFetch } = await import('./apiFetch');
97
+ await apiFetch('/api/admin/users');
98
+ expect(calls[0]).toBe('/api/admin/users');
99
+ });
37
100
  });
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export declare const VERSION = "0.23.2";
2
+ export declare const VERSION = "0.24.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export const VERSION = '0.23.2';
2
+ export const VERSION = '0.24.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"