sh3-core 0.10.2 → 0.10.4

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.
Files changed (49) hide show
  1. package/dist/api.d.ts +2 -0
  2. package/dist/api.js +1 -0
  3. package/dist/conflicts/ConflictModal.svelte +131 -0
  4. package/dist/conflicts/ConflictModal.svelte.d.ts +19 -0
  5. package/dist/conflicts/DetailView.svelte +198 -0
  6. package/dist/conflicts/DetailView.svelte.d.ts +17 -0
  7. package/dist/conflicts/PromptView.svelte +55 -0
  8. package/dist/conflicts/PromptView.svelte.d.ts +9 -0
  9. package/dist/conflicts/adapter-documents.d.ts +3 -0
  10. package/dist/conflicts/adapter-documents.js +119 -0
  11. package/dist/conflicts/api.d.ts +108 -0
  12. package/dist/conflicts/api.js +33 -0
  13. package/dist/conflicts/most-recent.d.ts +3 -0
  14. package/dist/conflicts/most-recent.js +23 -0
  15. package/dist/conflicts/most-recent.test.d.ts +1 -0
  16. package/dist/conflicts/most-recent.test.js +45 -0
  17. package/dist/conflicts/renderer-registry.d.ts +7 -0
  18. package/dist/conflicts/renderer-registry.js +59 -0
  19. package/dist/conflicts/renderer-registry.test.d.ts +1 -0
  20. package/dist/conflicts/renderer-registry.test.js +124 -0
  21. package/dist/conflicts/renderers/MetaOnlyRenderer.svelte +73 -0
  22. package/dist/conflicts/renderers/MetaOnlyRenderer.svelte.d.ts +9 -0
  23. package/dist/conflicts/renderers/TextDiffRenderer.svelte +154 -0
  24. package/dist/conflicts/renderers/TextDiffRenderer.svelte.d.ts +9 -0
  25. package/dist/conflicts/renderers/index.d.ts +8 -0
  26. package/dist/conflicts/renderers/index.js +63 -0
  27. package/dist/conflicts/resolve-primitive.d.ts +2 -0
  28. package/dist/conflicts/resolve-primitive.js +55 -0
  29. package/dist/conflicts/shell-api.d.ts +2 -0
  30. package/dist/conflicts/shell-api.js +13 -0
  31. package/dist/documents/browse.d.ts +26 -0
  32. package/dist/documents/browse.js +16 -0
  33. package/dist/documents/browse.test.js +97 -0
  34. package/dist/documents/handle.js +5 -0
  35. package/dist/documents/handle.test.js +37 -0
  36. package/dist/documents/http-backend.d.ts +1 -0
  37. package/dist/documents/http-backend.js +9 -0
  38. package/dist/documents/http-backend.test.d.ts +1 -0
  39. package/dist/documents/http-backend.test.js +39 -0
  40. package/dist/documents/types.d.ts +14 -0
  41. package/dist/keys/ConsentDialog.svelte +1 -0
  42. package/dist/primitives/base.css +140 -1
  43. package/dist/shell-shard/InputLine.svelte +1 -0
  44. package/dist/shellRuntime.svelte.d.ts +3 -0
  45. package/dist/shellRuntime.svelte.js +2 -0
  46. package/dist/tokens.css +3 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ import type { ShardContext } from '../shards/types';
2
+ export interface ConflictItem {
3
+ /** Caller-unique within the batch. */
4
+ id: string;
5
+ /** Shown in the left rail and detail header, e.g. "notes/meeting.md". */
6
+ label: string;
7
+ /** Default inferred from `extension`. 'custom' must be set by caller. */
8
+ kind?: 'text' | 'binary' | 'custom';
9
+ /** Hint for renderer selection, e.g. '.md'. Used for kind inference too. */
10
+ extension?: string;
11
+ /** >=1 branch. Doc-zone conflicts produce >=2. */
12
+ branches: ConflictBranch[];
13
+ /** Opaque caller payload; echoed back in output for tracking. */
14
+ meta?: Record<string, unknown>;
15
+ }
16
+ export interface ConflictBranch {
17
+ /** 'local' | peerId | any caller-defined label. */
18
+ origin: string;
19
+ version: number;
20
+ /** Epoch ms of the branch write. */
21
+ at: number;
22
+ /** Text content; absent for binary or not-yet-fetched. */
23
+ content?: string;
24
+ /** Used by oversized-text fallback in MetaOnlyRenderer. */
25
+ sizeBytes?: number;
26
+ /** Short hash display for binary branches. */
27
+ hashPreview?: string;
28
+ }
29
+ export interface ResolveOptions {
30
+ /** Modal title override; defaults to "Resolve conflict(s)". */
31
+ title?: string;
32
+ /**
33
+ * Comparator for "most recent"; default compares `at` descending.
34
+ * A throwing comparator degrades to the default — never crashes the batch.
35
+ */
36
+ mostRecentBy?: (a: ConflictBranch, b: ConflictBranch) => number;
37
+ /** Force the three-way prompt even for single-item batches. */
38
+ requirePrompt?: boolean;
39
+ }
40
+ export type ResolveOutcome = {
41
+ status: 'resolved';
42
+ choices: Array<{
43
+ itemId: string;
44
+ chosen: ConflictBranch;
45
+ meta?: Record<string, unknown>;
46
+ }>;
47
+ skipped: string[];
48
+ } | {
49
+ status: 'cancelled';
50
+ };
51
+ export interface ResolveDocumentsInput {
52
+ /** Target shard. Cross-shard requires documents:read + documents:write. */
53
+ shardId: string;
54
+ /** Paths to resolve. Pre-filtered to actual conflicts inside the adapter. */
55
+ paths: string[];
56
+ }
57
+ export type DocsResolveOutcome = {
58
+ status: 'resolved';
59
+ resolved: Array<{
60
+ path: string;
61
+ chosen: ConflictBranch;
62
+ appliedVersion: number;
63
+ }>;
64
+ failed: Array<{
65
+ path: string;
66
+ error: string;
67
+ }>;
68
+ skipped: string[];
69
+ } | {
70
+ status: 'cancelled';
71
+ };
72
+ /**
73
+ * Public contribution point id — shards register specialized renderers
74
+ * via `ctx.contributions.register(CONFLICT_RENDERER_POINT, descriptor)`.
75
+ * Convention is priority 10+ for contributions; built-ins register at
76
+ * priority 0 (fallback) and 1 (default text renderer).
77
+ */
78
+ export declare const CONFLICT_RENDERER_POINT = "sh3-core.conflictRenderer";
79
+ export interface ConflictRenderer {
80
+ /** Shard-prefixed unique id, e.g. 'sh3-editor.guml-conflict-renderer'. */
81
+ id: string;
82
+ /** Called on every candidate item; the highest-priority truthy one wins. */
83
+ appliesTo(item: ConflictItem): boolean;
84
+ /** Higher wins. Default fallback = 0, default text = 1, contributions ≥ 10. */
85
+ priority?: number;
86
+ /** Mount the renderer into the provided container; return an unmount. */
87
+ mount(container: HTMLElement, props: ConflictRendererProps): () => void;
88
+ }
89
+ export interface ConflictRendererProps {
90
+ item: ConflictItem;
91
+ /** The currently chosen branch origin; mirrors the parent's state. */
92
+ selectedOrigin: string;
93
+ /** Renderers call this to drive selection; parent re-renders with new value. */
94
+ onSelect(origin: string): void;
95
+ }
96
+ /** Thrown when the adapter is called cross-shard without the required permissions. */
97
+ export declare class ConflictPermissionError extends Error {
98
+ constructor(message: string);
99
+ }
100
+ /** Thrown when the caller's shard deactivates while a conflict session is open. */
101
+ export declare class ConflictSessionOrphanedError extends Error {
102
+ constructor();
103
+ }
104
+ /** Runtime shape of `shell.conflicts`. */
105
+ export interface ConflictsApi {
106
+ resolve(items: ConflictItem[], opts?: ResolveOptions): Promise<ResolveOutcome>;
107
+ resolveDocuments(ctx: ShardContext, input: ResolveDocumentsInput, opts?: ResolveOptions): Promise<DocsResolveOutcome>;
108
+ }
@@ -0,0 +1,33 @@
1
+ /*
2
+ * Public types for the conflict manager view.
3
+ *
4
+ * `shell.conflicts` exposes two entry points:
5
+ * - resolve(items, opts): generic primitive
6
+ * - resolveDocuments(ctx, input, opts): doc-zone adapter
7
+ *
8
+ * Shards construct ConflictItem arrays themselves (generic path) or pass
9
+ * { shardId, paths } to the adapter (doc path). The doc-zone adapter
10
+ * layers on top of the generic primitive — the shell does not know about
11
+ * documents; the adapter does.
12
+ */
13
+ /**
14
+ * Public contribution point id — shards register specialized renderers
15
+ * via `ctx.contributions.register(CONFLICT_RENDERER_POINT, descriptor)`.
16
+ * Convention is priority 10+ for contributions; built-ins register at
17
+ * priority 0 (fallback) and 1 (default text renderer).
18
+ */
19
+ export const CONFLICT_RENDERER_POINT = 'sh3-core.conflictRenderer';
20
+ /** Thrown when the adapter is called cross-shard without the required permissions. */
21
+ export class ConflictPermissionError extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'ConflictPermissionError';
25
+ }
26
+ }
27
+ /** Thrown when the caller's shard deactivates while a conflict session is open. */
28
+ export class ConflictSessionOrphanedError extends Error {
29
+ constructor() {
30
+ super('Caller shard deactivated while conflict session was active');
31
+ this.name = 'ConflictSessionOrphanedError';
32
+ }
33
+ }
@@ -0,0 +1,3 @@
1
+ import type { ConflictBranch } from './api';
2
+ export declare function defaultMostRecentBy(a: ConflictBranch, b: ConflictBranch): number;
3
+ export declare function pickMostRecent(branches: ConflictBranch[], cmp: (a: ConflictBranch, b: ConflictBranch) => number): ConflictBranch | null;
@@ -0,0 +1,23 @@
1
+ /*
2
+ * Default "most recent" comparator + pickMostRecent helper.
3
+ *
4
+ * Default rule: compare by ConflictBranch.at descending (newer wins).
5
+ * Callers can override via ResolveOptions.mostRecentBy; pickMostRecent
6
+ * wraps the comparator in a try/catch so a broken override degrades to
7
+ * the default instead of exploding the batch.
8
+ */
9
+ export function defaultMostRecentBy(a, b) {
10
+ // Newer (higher `at`) should come first — negative when a is newer.
11
+ return b.at - a.at;
12
+ }
13
+ export function pickMostRecent(branches, cmp) {
14
+ if (branches.length === 0)
15
+ return null;
16
+ try {
17
+ return [...branches].sort(cmp)[0];
18
+ }
19
+ catch (err) {
20
+ console.warn('[sh3-core] mostRecentBy comparator threw; falling back to default', err);
21
+ return [...branches].sort(defaultMostRecentBy)[0];
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { defaultMostRecentBy, pickMostRecent } from './most-recent';
3
+ function branch(origin, at, version = 1) {
4
+ return { origin, at, version };
5
+ }
6
+ describe('defaultMostRecentBy', () => {
7
+ it('descending by `at` — newest first', () => {
8
+ const out = [branch('a', 100), branch('b', 200), branch('c', 150)]
9
+ .slice()
10
+ .sort(defaultMostRecentBy);
11
+ expect(out.map((b) => b.origin)).toEqual(['b', 'c', 'a']);
12
+ });
13
+ it('stable when `at` is equal — returns 0', () => {
14
+ expect(defaultMostRecentBy(branch('a', 100), branch('b', 100))).toBe(0);
15
+ });
16
+ });
17
+ describe('pickMostRecent', () => {
18
+ let warnSpy;
19
+ beforeEach(() => {
20
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
21
+ });
22
+ afterEach(() => {
23
+ warnSpy.mockRestore();
24
+ });
25
+ it('returns the first branch by comparator', () => {
26
+ const a = branch('a', 100);
27
+ const b = branch('b', 200);
28
+ expect(pickMostRecent([a, b], defaultMostRecentBy)).toBe(b);
29
+ });
30
+ it('tolerates a broken caller comparator by falling back to default', () => {
31
+ const boom = () => { throw new Error('boom'); };
32
+ const b = branch('b', 200);
33
+ const out = pickMostRecent([branch('a', 100), b], boom);
34
+ expect(out).toBe(b);
35
+ expect(warnSpy).toHaveBeenCalled();
36
+ });
37
+ it('returns null for empty input', () => {
38
+ expect(pickMostRecent([], defaultMostRecentBy)).toBeNull();
39
+ });
40
+ it('caller comparator can pick an arbitrary branch', () => {
41
+ var _a;
42
+ const cmp = (a, b) => a.origin === 'local' ? -1 : b.origin === 'local' ? 1 : 0;
43
+ expect((_a = pickMostRecent([branch('peer', 999), branch('local', 1)], cmp)) === null || _a === void 0 ? void 0 : _a.origin).toBe('local');
44
+ });
45
+ });
@@ -0,0 +1,7 @@
1
+ import type { ConflictItem, ConflictRenderer } from './api';
2
+ export declare const TEXT_EXTENSIONS: readonly string[];
3
+ export declare function inferKind(item: ConflictItem): 'text' | 'binary' | 'custom';
4
+ export declare function registerBuiltInRenderer(r: ConflictRenderer): () => void;
5
+ export declare function pickRenderer(item: ConflictItem, contributed?: ConflictRenderer[]): ConflictRenderer | null;
6
+ /** Test-only helper. */
7
+ export declare function _resetForTests(): void;
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Renderer selection for the conflict manager view.
3
+ *
4
+ * Two registration surfaces:
5
+ * - Built-in renderers (TextDiffRenderer, MetaOnlyRenderer) are
6
+ * registered from within sh3-core at first `shell.conflicts` use via
7
+ * ensureBuiltInRenderersRegistered().
8
+ * - Contributed renderers arrive via the ctx.contributions runtime
9
+ * under CONFLICT_RENDERER_POINT. The conflict modal reads the
10
+ * current list from the contributions registry each render.
11
+ *
12
+ * Selection: among renderers whose appliesTo(item) is true, the highest
13
+ * priority wins. Built-in fallback is priority 0 (MetaOnly), built-in
14
+ * text is priority 1, contributed renderers typically >= 10.
15
+ *
16
+ * Renderer appliesTo is wrapped in try/catch; a throwing check excludes
17
+ * that renderer from the match set, never crashes the pick.
18
+ *
19
+ * _resetForTests() clears the built-in set between test runs.
20
+ */
21
+ export const TEXT_EXTENSIONS = Object.freeze([
22
+ '.md', '.txt', '.json', '.guml', '.css', '.js', '.ts', '.tsx', '.jsx',
23
+ '.html', '.svg', '.csv', '.yaml', '.yml', '.xml', '.log', '.ini', '.toml',
24
+ ]);
25
+ export function inferKind(item) {
26
+ if (item.kind)
27
+ return item.kind;
28
+ if (item.extension && TEXT_EXTENSIONS.includes(item.extension.toLowerCase())) {
29
+ return 'text';
30
+ }
31
+ return 'binary';
32
+ }
33
+ const builtIns = [];
34
+ export function registerBuiltInRenderer(r) {
35
+ builtIns.push(r);
36
+ return () => {
37
+ const i = builtIns.indexOf(r);
38
+ if (i >= 0)
39
+ builtIns.splice(i, 1);
40
+ };
41
+ }
42
+ export function pickRenderer(item, contributed = []) {
43
+ var _a;
44
+ const candidates = [...builtIns, ...contributed]
45
+ .filter((r) => {
46
+ try {
47
+ return r.appliesTo(item);
48
+ }
49
+ catch (_a) {
50
+ return false;
51
+ }
52
+ })
53
+ .sort((a, b) => { var _a, _b; return ((_a = b.priority) !== null && _a !== void 0 ? _a : 0) - ((_b = a.priority) !== null && _b !== void 0 ? _b : 0); });
54
+ return (_a = candidates[0]) !== null && _a !== void 0 ? _a : null;
55
+ }
56
+ /** Test-only helper. */
57
+ export function _resetForTests() {
58
+ builtIns.length = 0;
59
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { inferKind, TEXT_EXTENSIONS, pickRenderer, registerBuiltInRenderer, _resetForTests, } from './renderer-registry';
3
+ function item(opts = {}) {
4
+ return Object.assign({ id: 'x', label: 'x', branches: [{ origin: 'local', version: 1, at: 1 }] }, opts);
5
+ }
6
+ describe('inferKind', () => {
7
+ for (const ext of ['.md', '.txt', '.json', '.guml', '.ts']) {
8
+ it(`recognises ${ext} as text`, () => {
9
+ expect(inferKind(item({ extension: ext }))).toBe('text');
10
+ });
11
+ }
12
+ it('unknown extension → binary', () => {
13
+ expect(inferKind(item({ extension: '.bin' }))).toBe('binary');
14
+ });
15
+ it('no extension → binary', () => {
16
+ expect(inferKind(item({}))).toBe('binary');
17
+ });
18
+ it('extension case-insensitive', () => {
19
+ expect(inferKind(item({ extension: '.MD' }))).toBe('text');
20
+ });
21
+ it('explicit kind wins over inference', () => {
22
+ expect(inferKind(item({ extension: '.md', kind: 'custom' }))).toBe('custom');
23
+ expect(inferKind(item({ extension: '.md', kind: 'binary' }))).toBe('binary');
24
+ });
25
+ it('TEXT_EXTENSIONS covers the canonical set', () => {
26
+ expect(TEXT_EXTENSIONS.includes('.md')).toBe(true);
27
+ expect(TEXT_EXTENSIONS.includes('.json')).toBe(true);
28
+ expect(TEXT_EXTENSIONS.includes('.guml')).toBe(true);
29
+ });
30
+ });
31
+ describe('pickRenderer', () => {
32
+ beforeEach(() => _resetForTests());
33
+ const noopMount = () => () => { };
34
+ it('picks the built-in text renderer for text items', () => {
35
+ var _a;
36
+ const textRenderer = {
37
+ id: 'sh3-core.text',
38
+ appliesTo: (it) => inferKind(it) === 'text',
39
+ priority: 1,
40
+ mount: noopMount,
41
+ };
42
+ const fallback = {
43
+ id: 'sh3-core.meta',
44
+ appliesTo: () => true,
45
+ priority: 0,
46
+ mount: noopMount,
47
+ };
48
+ registerBuiltInRenderer(textRenderer);
49
+ registerBuiltInRenderer(fallback);
50
+ expect((_a = pickRenderer(item({ extension: '.md' }))) === null || _a === void 0 ? void 0 : _a.id).toBe('sh3-core.text');
51
+ });
52
+ it('falls back to meta when text renderer declines', () => {
53
+ var _a;
54
+ const textRenderer = {
55
+ id: 'sh3-core.text',
56
+ appliesTo: (it) => inferKind(it) === 'text',
57
+ priority: 1,
58
+ mount: noopMount,
59
+ };
60
+ const fallback = {
61
+ id: 'sh3-core.meta',
62
+ appliesTo: () => true,
63
+ priority: 0,
64
+ mount: noopMount,
65
+ };
66
+ registerBuiltInRenderer(textRenderer);
67
+ registerBuiltInRenderer(fallback);
68
+ expect((_a = pickRenderer(item({ kind: 'binary' }))) === null || _a === void 0 ? void 0 : _a.id).toBe('sh3-core.meta');
69
+ });
70
+ it('a contribution with higher priority wins over built-ins', () => {
71
+ var _a;
72
+ const textRenderer = {
73
+ id: 'sh3-core.text',
74
+ appliesTo: (it) => inferKind(it) === 'text',
75
+ priority: 1,
76
+ mount: noopMount,
77
+ };
78
+ registerBuiltInRenderer(textRenderer);
79
+ const contributed = {
80
+ id: 'sh3-editor.guml',
81
+ appliesTo: (it) => it.extension === '.guml',
82
+ priority: 10,
83
+ mount: noopMount,
84
+ };
85
+ expect((_a = pickRenderer(item({ extension: '.guml' }), [contributed])) === null || _a === void 0 ? void 0 : _a.id).toBe('sh3-editor.guml');
86
+ });
87
+ it('contribution whose appliesTo returns false is ignored', () => {
88
+ var _a;
89
+ const textRenderer = {
90
+ id: 'sh3-core.text',
91
+ appliesTo: (it) => inferKind(it) === 'text',
92
+ priority: 1,
93
+ mount: noopMount,
94
+ };
95
+ registerBuiltInRenderer(textRenderer);
96
+ const contributed = {
97
+ id: 'sh3-editor.guml',
98
+ appliesTo: () => false,
99
+ priority: 999,
100
+ mount: noopMount,
101
+ };
102
+ expect((_a = pickRenderer(item({ extension: '.md' }), [contributed])) === null || _a === void 0 ? void 0 : _a.id).toBe('sh3-core.text');
103
+ });
104
+ it('a renderer that throws from appliesTo is excluded, not fatal', () => {
105
+ var _a;
106
+ const boom = {
107
+ id: 'bad.one',
108
+ appliesTo: () => { throw new Error('boom'); },
109
+ priority: 999,
110
+ mount: noopMount,
111
+ };
112
+ const fallback = {
113
+ id: 'sh3-core.meta',
114
+ appliesTo: () => true,
115
+ priority: 0,
116
+ mount: noopMount,
117
+ };
118
+ registerBuiltInRenderer(fallback);
119
+ expect((_a = pickRenderer(item({}), [boom])) === null || _a === void 0 ? void 0 : _a.id).toBe('sh3-core.meta');
120
+ });
121
+ it('no renderer matches → null', () => {
122
+ expect(pickRenderer(item({ extension: '.bin' }), [])).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ import type { ConflictItem } from '../api';
3
+
4
+ interface Props {
5
+ item: ConflictItem;
6
+ selectedOrigin: string;
7
+ onSelect: (origin: string) => void;
8
+ }
9
+ const { item, selectedOrigin, onSelect }: Props = $props();
10
+
11
+ function formatSize(n?: number): string {
12
+ if (typeof n !== 'number') return '—';
13
+ if (n < 1024) return `${n} B`;
14
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
15
+ return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
16
+ }
17
+
18
+ function relTime(at: number): string {
19
+ const delta = Date.now() - at;
20
+ if (delta < 60_000) return 'just now';
21
+ if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`;
22
+ if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)}h ago`;
23
+ return new Date(at).toLocaleString();
24
+ }
25
+ </script>
26
+
27
+ <div class="sh3-conflict-meta">
28
+ <div class="sh3-conflict-meta-note">
29
+ No preview available for this content. Pick the branch you want to keep.
30
+ </div>
31
+ <ul class="sh3-conflict-meta-list">
32
+ {#each item.branches as b (b.origin)}
33
+ <li class="sh3-conflict-meta-li">
34
+ <button
35
+ type="button"
36
+ class="sh3-conflict-meta-row"
37
+ class:selected={b.origin === selectedOrigin}
38
+ onclick={() => onSelect(b.origin)}
39
+ >
40
+ <div class="sh3-conflict-meta-origin">{b.origin}</div>
41
+ <div class="sh3-conflict-meta-detail">
42
+ v{b.version} · {relTime(b.at)} · {formatSize(b.sizeBytes)}
43
+ {#if b.hashPreview} · <code>{b.hashPreview}</code>{/if}
44
+ </div>
45
+ </button>
46
+ </li>
47
+ {/each}
48
+ </ul>
49
+ </div>
50
+
51
+ <style>
52
+ .sh3-conflict-meta { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
53
+ .sh3-conflict-meta-note { color: var(--shell-fg-muted, #888); font-size: 0.875rem; font-style: italic; }
54
+ .sh3-conflict-meta-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
55
+ .sh3-conflict-meta-li { margin: 0; }
56
+ .sh3-conflict-meta-row {
57
+ width: 100%;
58
+ text-align: left;
59
+ padding: 10px 12px;
60
+ background: var(--shell-input-bg, #2a2a2a);
61
+ color: var(--shell-fg, #e0e0e0);
62
+ border: 1px solid var(--shell-border, #444);
63
+ border-radius: var(--shell-radius-sm);
64
+ cursor: pointer;
65
+ font: inherit;
66
+ }
67
+ .sh3-conflict-meta-row.selected {
68
+ border-color: var(--shell-accent, #007acc);
69
+ box-shadow: 0 0 0 2px var(--shell-focus-ring, rgba(0,122,204,0.3));
70
+ }
71
+ .sh3-conflict-meta-origin { font-weight: 600; font-size: 0.875rem; }
72
+ .sh3-conflict-meta-detail { color: var(--shell-fg-muted, #888); font-size: 0.75rem; margin-top: 2px; }
73
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { ConflictItem } from '../api';
2
+ interface Props {
3
+ item: ConflictItem;
4
+ selectedOrigin: string;
5
+ onSelect: (origin: string) => void;
6
+ }
7
+ declare const MetaOnlyRenderer: import("svelte").Component<Props, {}, "">;
8
+ type MetaOnlyRenderer = ReturnType<typeof MetaOnlyRenderer>;
9
+ export default MetaOnlyRenderer;
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import type { ConflictItem } from '../api';
3
+
4
+ interface Props {
5
+ item: ConflictItem;
6
+ selectedOrigin: string;
7
+ onSelect: (origin: string) => void;
8
+ }
9
+ const { item, selectedOrigin, onSelect }: Props = $props();
10
+
11
+ // 3+ branch mode: user picks a "reference" branch to compare against.
12
+ let reference = $state<string>('');
13
+ $effect(() => {
14
+ if (!reference || !item.branches.find((b) => b.origin === reference)) {
15
+ reference = item.branches[0]?.origin ?? 'local';
16
+ }
17
+ });
18
+ const isDualMode = $derived(item.branches.length === 2);
19
+
20
+ const leftBranch = $derived(
21
+ isDualMode
22
+ ? item.branches[0]
23
+ : (item.branches.find((b) => b.origin === reference) ?? item.branches[0]),
24
+ );
25
+ const rightBranch = $derived(
26
+ isDualMode
27
+ ? item.branches[1]
28
+ : (item.branches.find((b) => b.origin === selectedOrigin) ?? item.branches[1] ?? item.branches[0]),
29
+ );
30
+
31
+ /** Line-level diff: matches lines at the same index; does not do LCS. */
32
+ function lineDiff(a: string, b: string): Array<{ kind: 'same' | 'left' | 'right'; text: string }> {
33
+ const al = (a ?? '').split(/\r?\n/);
34
+ const bl = (b ?? '').split(/\r?\n/);
35
+ const out: Array<{ kind: 'same' | 'left' | 'right'; text: string }> = [];
36
+ const max = Math.max(al.length, bl.length);
37
+ for (let i = 0; i < max; i++) {
38
+ const left = al[i];
39
+ const right = bl[i];
40
+ if (left === right) {
41
+ if (typeof left === 'string') out.push({ kind: 'same', text: left });
42
+ } else {
43
+ if (typeof left === 'string') out.push({ kind: 'left', text: left });
44
+ if (typeof right === 'string') out.push({ kind: 'right', text: right });
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const diff = $derived.by(() => {
51
+ if (!leftBranch || !rightBranch) return [];
52
+ return lineDiff(leftBranch.content ?? '', rightBranch.content ?? '');
53
+ });
54
+ const diffCount = $derived(diff.filter((d) => d.kind !== 'same').length);
55
+ </script>
56
+
57
+ <div class="sh3-conflict-text">
58
+ {#if isDualMode && leftBranch && rightBranch}
59
+ <div class="sh3-conflict-text-dual">
60
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
61
+ <div
62
+ class="sh3-conflict-text-pane"
63
+ class:selected={leftBranch.origin === selectedOrigin}
64
+ role="button"
65
+ tabindex="0"
66
+ onclick={() => onSelect(leftBranch.origin)}
67
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(leftBranch.origin); }}
68
+ >
69
+ <header class="sh3-conflict-text-head">{leftBranch.origin} · v{leftBranch.version}</header>
70
+ <pre class="sh3-conflict-text-body"><code>{leftBranch.content ?? ''}</code></pre>
71
+ </div>
72
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
73
+ <div
74
+ class="sh3-conflict-text-pane"
75
+ class:selected={rightBranch.origin === selectedOrigin}
76
+ role="button"
77
+ tabindex="0"
78
+ onclick={() => onSelect(rightBranch.origin)}
79
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(rightBranch.origin); }}
80
+ >
81
+ <header class="sh3-conflict-text-head">{rightBranch.origin} · v{rightBranch.version}</header>
82
+ <pre class="sh3-conflict-text-body"><code>{rightBranch.content ?? ''}</code></pre>
83
+ </div>
84
+ </div>
85
+ <details class="sh3-conflict-text-diff">
86
+ <summary>Line diff ({diffCount} difference{diffCount === 1 ? '' : 's'})</summary>
87
+ <pre><code>{#each diff as d, i (i)}<span class={`diff-${d.kind}`}>{d.kind === 'same' ? ' ' : d.kind === 'left' ? '- ' : '+ '}{d.text}{'\n'}</span>{/each}</code></pre>
88
+ </details>
89
+ {:else}
90
+ <div class="sh3-conflict-text-tabs">
91
+ <div class="sh3-conflict-text-picker">
92
+ <label>
93
+ Reference:
94
+ <select bind:value={reference}>
95
+ {#each item.branches as b (b.origin)}
96
+ <option value={b.origin}>{b.origin}</option>
97
+ {/each}
98
+ </select>
99
+ </label>
100
+ </div>
101
+ <div class="sh3-conflict-text-tablist">
102
+ {#each item.branches as b (b.origin)}
103
+ <button
104
+ class="sh3-conflict-text-tab"
105
+ class:selected={b.origin === selectedOrigin}
106
+ onclick={() => onSelect(b.origin)}
107
+ >
108
+ {b.origin} · v{b.version}
109
+ </button>
110
+ {/each}
111
+ </div>
112
+ {#if rightBranch}
113
+ <pre class="sh3-conflict-text-body"><code>{rightBranch.content ?? ''}</code></pre>
114
+ {/if}
115
+ </div>
116
+ {/if}
117
+ </div>
118
+
119
+ <style>
120
+ .sh3-conflict-text { display: flex; flex-direction: column; gap: 12px; padding: 12px; height: 100%; min-height: 0; }
121
+ .sh3-conflict-text-dual { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; min-height: 0; flex: 1; }
122
+ .sh3-conflict-text-pane {
123
+ display: flex; flex-direction: column; min-height: 0;
124
+ border: 1px solid var(--shell-border, #444);
125
+ border-radius: var(--shell-radius-sm);
126
+ background: var(--shell-input-bg, #2a2a2a);
127
+ cursor: pointer;
128
+ }
129
+ .sh3-conflict-text-pane.selected {
130
+ border-color: var(--shell-accent, #007acc);
131
+ box-shadow: 0 0 0 2px var(--shell-focus-ring, rgba(0,122,204,0.3));
132
+ }
133
+ .sh3-conflict-text-head { padding: 6px 10px; font-size: 0.75rem; color: var(--shell-fg-muted, #888); border-bottom: 1px solid var(--shell-border, #444); }
134
+ .sh3-conflict-text-body {
135
+ margin: 0; padding: 10px; flex: 1; overflow: auto;
136
+ font-family: var(--shell-font-mono, monospace); font-size: 0.8125rem;
137
+ }
138
+ .sh3-conflict-text-diff { margin-top: 8px; }
139
+ .sh3-conflict-text-diff pre { background: var(--shell-input-bg, #2a2a2a); padding: 8px; overflow: auto; max-height: 240px; }
140
+ :global(.diff-left) { color: var(--shell-error, #e35); }
141
+ :global(.diff-right) { color: var(--shell-success, #3a3); }
142
+ :global(.diff-same) { opacity: 0.7; }
143
+ .sh3-conflict-text-tabs { display: flex; flex-direction: column; gap: 8px; min-height: 0; flex: 1; }
144
+ .sh3-conflict-text-picker select {
145
+ background: var(--shell-input-bg, #2a2a2a); color: var(--shell-fg, #e0e0e0);
146
+ border: 1px solid var(--shell-border, #444); padding: 2px 6px;
147
+ }
148
+ .sh3-conflict-text-tablist { display: flex; gap: 4px; flex-wrap: wrap; }
149
+ .sh3-conflict-text-tab {
150
+ padding: 4px 10px; background: var(--shell-input-bg, #2a2a2a); border: 1px solid var(--shell-border, #444);
151
+ border-radius: var(--shell-radius-sm); color: var(--shell-fg, #e0e0e0); cursor: pointer; font-size: 0.8125rem;
152
+ }
153
+ .sh3-conflict-text-tab.selected { border-color: var(--shell-accent, #007acc); }
154
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { ConflictItem } from '../api';
2
+ interface Props {
3
+ item: ConflictItem;
4
+ selectedOrigin: string;
5
+ onSelect: (origin: string) => void;
6
+ }
7
+ declare const TextDiffRenderer: import("svelte").Component<Props, {}, "">;
8
+ type TextDiffRenderer = ReturnType<typeof TextDiffRenderer>;
9
+ export default TextDiffRenderer;
@@ -0,0 +1,8 @@
1
+ import type { ConflictRenderer } from '../api';
2
+ /** Max size per branch before we degrade to MetaOnlyRenderer. */
3
+ export declare const OVERSIZED_TEXT_LIMIT_BYTES = 1048576;
4
+ export declare const textDiffRenderer: ConflictRenderer;
5
+ export declare const metaOnlyRenderer: ConflictRenderer;
6
+ export declare function ensureBuiltInRenderersRegistered(): void;
7
+ /** Test-only: re-enable registration after _resetForTests(). */
8
+ export declare function _resetBuiltInRegistrationFlag(): void;