Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e009fcb
chore(svelte5): slice 1 — bump to svelte 5 + svelte-check 4, wire com…
chhoumann May 29, 2026
5af3320
feat(svelte5): slice 2 — shared mount harness + dnd-reorder utils
chhoumann May 29, 2026
e10f621
feat(svelte5): slice 3 — convert ObsidianIcon to runes
chhoumann May 29, 2026
98d080f
feat(svelte5): slice 4 — macro cluster + CommandSequenceEditor host t…
chhoumann May 29, 2026
459b255
feat(svelte5): slice 5 — recursive choiceList cluster to runes
chhoumann May 29, 2026
71bde5e
feat(svelte5): slice 6 — remaining components + hosts to runes/mount
chhoumann May 29, 2026
35c340b
chore(svelte5): slice 7 — lint .svelte with eslint-plugin-svelte + cl…
chhoumann May 29, 2026
7d6fece
fix(svelte5): persist conditional then/else branch edits (+configure-…
chhoumann May 29, 2026
3bd4a5a
test(e2e): real-GUI regression test for conditional Then-branch persi…
chhoumann May 29, 2026
c79fb5b
docs(svelte5): correct stale smoke-test comments after ObsidianIcon r…
chhoumann May 29, 2026
08d6304
chore(svelte5): add minimal svelte.config.js for check/test/lint tooling
chhoumann May 29, 2026
47c1a0c
refactor(svelte5): replace as-any casts in ChoiceView with an isMulti…
chhoumann May 29, 2026
dc940f7
refactor(svelte5): compile-time guard for the $state.snapshot persist…
chhoumann May 29, 2026
8d61ac8
fix(svelte5): unload markdown-render Component on destroy in choice rows
chhoumann May 29, 2026
7773f15
chore: stop tracking Claude Code runtime state (.claude/scheduled_tas…
chhoumann May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ data.json
src/**/*.js

# e2e test failure artifacts
.obsidian-e2e-artifacts
# Test coverage output
coverage/
.obsidian-e2e-artifacts
# Test coverage output
coverage/

# Claude Code session/runtime state (ephemeral, machine-local)
.claude/scheduled_tasks.lock
.claude/scheduled_tasks.json
150 changes: 101 additions & 49 deletions bun.lock

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import svelte from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
import globals from 'globals';

export default [
Expand Down Expand Up @@ -40,6 +42,27 @@ export default [
'@typescript-eslint/restrict-template-expressions': 'off',
},
},
// Lint Svelte 5 components (runes-aware via svelte-eslint-parser).
...svelte.configs['flat/recommended'],
{
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parser: svelteParser,
parserOptions: {
// Parse <script lang="ts"> with the TS parser.
parser: typescriptParser,
},
globals: {
...globals.browser,
},
},
rules: {
// The package modals' Set/Map state is updated by immutable REASSIGNMENT
// (new Set/Map -> assign), which is reactive under $state. SvelteSet/SvelteMap
// are only needed for in-place mutation, so this rule is a false positive here.
'svelte/prefer-svelte-reactivity': 'off',
},
},
// Special rules for main.ts to preserve critical import order
{
files: ['src/main.ts'],
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"@popperjs/core": "^2.11.8",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@sveltejs/vite-plugin-svelte": "^6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "24.0.12",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
Expand All @@ -28,15 +32,17 @@
"esbuild": "0.28",
"esbuild-svelte": "^0.9.5",
"eslint": "10",
"eslint-plugin-svelte": "^3.18.0",
"globals": "17",
"jsdom": "29",
"obsidian": "1.11.4",
"obsidian-dataview": "^0.5.68",
"obsidian-e2e": "0.6.0",
"semantic-release": "^24.2.9",
"svelte": "^4.2.20",
"svelte-check": "^3.8.6",
"svelte": "^5",
Comment thread
chhoumann marked this conversation as resolved.
"svelte-check": "^4",
"svelte-dnd-action": "0.9.69",
"svelte-eslint-parser": "^1.6.1",
"svelte-preprocess": "^6.0.5",
"three-way-merge": "^0.1.0",
"tslib": "^2.8.1",
Expand Down
15 changes: 6 additions & 9 deletions src/gui/ChoiceBuilder/FolderList.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
<script lang="ts">
import ObsidianIcon from "../components/ObsidianIcon.svelte";
import type { FolderListProps } from "./folderListProps.svelte";

export let folders: string[];
export let deleteFolder: (folder: string) => void;
export const updateFolders = (newFolders: string[]) => {
folders = newFolders;
}
let { folders, deleteFolder }: FolderListProps = $props();
</script>

<div class="quickAddFolderListGrid quickAddCommandList">
{#each folders as folder, i}
{#each folders as folder (folder)}
Comment thread
chhoumann marked this conversation as resolved.
<div class="quickAddCommandListItem">
<span>{folder}</span>
<span
<span
role="button"
tabindex="0"
on:click={() => deleteFolder(folder)}
on:keypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteFolder(folder)}
onclick={() => deleteFolder(folder)}
onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && deleteFolder(folder)}
class="clickable"
>
<ObsidianIcon iconId="trash-2" size={16} />
Expand Down
17 changes: 12 additions & 5 deletions src/gui/ChoiceBuilder/choiceBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type App, Modal, Setting, setIcon } from "obsidian";
import type { SvelteComponent } from "svelte";
import type { MountHandle } from "../svelte/mountComponent";
import type IChoice from "../../types/choices/IChoice";
import type { FileViewMode2, OpenLocation } from "../../types/fileOpening";
import {
Expand All @@ -23,7 +23,7 @@ export abstract class ChoiceBuilder extends Modal {
private resolvePromise: (input: IChoice) => void;
public waitForClose: Promise<IChoice>;
abstract choice: IChoice;
protected svelteElements: SvelteComponent[] = [];
protected svelteElements: MountHandle[] = [];

protected constructor(app: App) {
super(app);
Expand All @@ -39,10 +39,19 @@ export abstract class ChoiceBuilder extends Modal {
protected abstract display(): unknown;

protected reload() {
// Unmount the previous Svelte components before re-rendering, otherwise their
// effects/subscriptions leak for the modal's lifetime (contentEl.empty() only
// removes DOM nodes, not the mounted components).
this.destroySvelteElements();
this.contentEl.empty();
this.display();
}

private destroySvelteElements() {
this.svelteElements.forEach((handle) => handle.destroy());
this.svelteElements = [];
}

protected addOnePageOverrideSetting(choice: IChoice): void {
new Setting(this.contentEl)
.setName("One-page input override")
Expand Down Expand Up @@ -213,9 +222,7 @@ export abstract class ChoiceBuilder extends Modal {

onClose() {
super.onClose();
this.svelteElements.forEach((el) => {
if (el && el.$destroy) el.$destroy();
});
this.destroySvelteElements();
this.resolvePromise(this.choice);
}
}
15 changes: 15 additions & 0 deletions src/gui/ChoiceBuilder/folderListProps.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Props for FolderList, shared with its imperative host (templateChoiceBuilder).
* The host owns a $state-backed instance and mutates `folders` to push add/remove
* updates into the mounted component — replacing FolderList's old exported
* `updateFolders()` bridge (which reassigned a prop, illegal under runes).
*/
export interface FolderListProps {
folders: string[];
deleteFolder: (folder: string) => void;
}

export function createFolderListProps(initial: FolderListProps): FolderListProps {
const props = $state(initial);
return props;
}
28 changes: 15 additions & 13 deletions src/gui/ChoiceBuilder/templateChoiceBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { ExclusiveSuggester } from "../suggesters/exclusiveSuggester";
import { FormatSyntaxSuggester } from "../suggesters/formatSyntaxSuggester";
import { ChoiceBuilder } from "./choiceBuilder";
import FolderList from "./FolderList.svelte";
import { createFolderListProps } from "./folderListProps.svelte";
import { mountComponent } from "../svelte/mountComponent";

export class TemplateChoiceBuilder extends ChoiceBuilder {
choice: ITemplateChoice;
Expand Down Expand Up @@ -238,22 +240,22 @@ export class TemplateChoiceBuilder extends ChoiceBuilder {
const folderList: HTMLDivElement =
folderSelectionContainer.createDiv("folderList");

const folderListEl = new FolderList({
target: folderList,
props: {
folders: this.choice.folder.folders,
deleteFolder: (folder: string) => {
this.choice.folder.folders = this.choice.folder.folders.filter(
(f) => f !== folder,
);
const folderListProps = createFolderListProps({
folders: [...this.choice.folder.folders],
deleteFolder: (folder: string) => {
this.choice.folder.folders = this.choice.folder.folders.filter(
(f) => f !== folder,
);

folderListEl.updateFolders(this.choice.folder.folders);
suggester.updateCurrentItems(this.choice.folder.folders);
},
// Push the new list into the mounted component (replaces updateFolders()).
folderListProps.folders = [...this.choice.folder.folders];
suggester.updateCurrentItems(this.choice.folder.folders);
},
});

this.svelteElements.push(folderListEl);
this.svelteElements.push(
mountComponent(folderList, FolderList, folderListProps),
);

const inputContainer = folderSelectionContainer.createDiv(
"folderInputContainer",
Expand Down Expand Up @@ -282,7 +284,7 @@ export class TemplateChoiceBuilder extends ChoiceBuilder {

this.choice.folder.folders.push(input);

folderListEl.updateFolders(this.choice.folder.folders);
folderListProps.folders = [...this.choice.folder.folders];
folderInput.inputEl.value = "";

suggester.updateCurrentItems(this.choice.folder.folders);
Expand Down
34 changes: 15 additions & 19 deletions src/gui/GlobalVariables/GlobalVariablesView.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { App } from "obsidian";
import { settingsStore } from "../../settingsStore";
import { FormatSyntaxSuggester } from "../suggesters/formatSyntaxSuggester";
import type QuickAdd from "../../main";

export let app: App;
export let plugin: QuickAdd;
let { app, plugin }: { app: App; plugin: QuickAdd } = $props();

type GV = { name: string; value: string };
let items: GV[] = [];

let unsubscribe: () => void;
let items = $state<GV[]>([]);

function uniqueName(base: string, existing: Set<string>): string {
if (!existing.has(base)) return base;
Expand Down Expand Up @@ -52,8 +48,8 @@

// Attach format suggester to inputs
function attachSuggester(el: HTMLTextAreaElement | HTMLInputElement) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const suggester = new FormatSyntaxSuggester(app, el, plugin);
// Constructed for its side effect (it wires itself to the element).
new FormatSyntaxSuggester(app, el, plugin);
return {
destroy() {
// Suggesters clean up themselves on blur/close; no explicit API needed
Expand All @@ -69,21 +65,21 @@
}, 200);
}

onMount(() => {
unsubscribe = settingsStore.subscribe(() => loadFromSettings());
$effect(() => {
const unsubscribe = settingsStore.subscribe(() => loadFromSettings());
loadFromSettings();
});
onDestroy(() => {
if (debounceTimer !== undefined) window.clearTimeout(debounceTimer);
unsubscribe && unsubscribe();
return () => {
if (debounceTimer !== undefined) window.clearTimeout(debounceTimer);
unsubscribe();
};
});
</script>

<div class="qa-gv">
<div class="qa-gv__header">
<div class="qa-gv__title">Global Variables</div>
<div class="qa-gv__actions">
<button class="mod-cta" on:click={addVariable}>Add variable</button>
<button class="mod-cta" onclick={addVariable}>Add variable</button>
</div>
</div>
<div class="qa-gv__desc">
Expand All @@ -96,24 +92,24 @@
<div class="qa-gv__cell qa-gv__value">Value</div>
<div class="qa-gv__cell qa-gv__ops">Actions</div>
</div>
{#each items as it, idx}
{#each items as it, idx (idx)}
<div class="qa-gv__row">
<div class="qa-gv__cell qa-gv__name">
<input type="text"
use:attachSuggester
bind:value={it.name}
on:input={() => { debouncedPersist(it); }}
oninput={() => { debouncedPersist(it); }}
placeholder="Name" />
</div>
<div class="qa-gv__cell qa-gv__value">
<textarea rows="2"
use:attachSuggester
bind:value={it.value}
on:input={() => debouncedPersist(it)}
oninput={() => debouncedPersist(it)}
placeholder="Snippet value (supports QuickAdd tokens)"></textarea>
</div>
<div class="qa-gv__cell qa-gv__ops">
<button class="qa-gv__btn danger" title="Delete" on:click={() => deleteVariable(items.indexOf(it))}>Delete</button>
<button class="qa-gv__btn danger" title="Delete" onclick={() => deleteVariable(items.indexOf(it))}>Delete</button>
</div>
</div>
{/each}
Expand Down
62 changes: 62 additions & 0 deletions src/gui/MacroGUIs/CommandList.conditional.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/svelte";

vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() }));

import { App } from "obsidian";
import CommandList from "./CommandList.svelte";
import { createCommandListProps } from "./commandListProps.svelte";
import { ConditionalCommand } from "../../types/macros/Conditional/ConditionalCommand";
import { WaitCommand } from "../../types/macros/QuickCommands/WaitCommand";

describe("CommandList conditional branch persistence", () => {
// Regression: the branch modal mutates the command, but the command rendered by
// CommandList is a $state proxy that does NOT write through to the host's
// commandsRef. CommandList must persist the mutation via saveCommands(snapshot).
it("persists then-branch edits through saveCommands as a plain snapshot", async () => {
const cond = new ConditionalCommand();
const saveCommands = vi.fn();

const props = createCommandListProps({
commands: [cond],
app: new App() as never,
plugin: {} as never,
deleteCommand: vi.fn(),
saveCommands,
// Simulate the branch editor mutating the command and reporting "changed".
onEditThenBranch: (command) => {
command.thenCommands = [new WaitCommand(100)];
return true;
},
});

const { getByLabelText } = render(CommandList, { props });
await fireEvent.click(getByLabelText("Edit then branch"));

await vi.waitFor(() => expect(saveCommands).toHaveBeenCalledTimes(1));
const saved = saveCommands.mock.calls[0][0] as Array<{ thenCommands?: unknown[] }>;
expect(saved[0].thenCommands).toHaveLength(1);
// The persisted payload must be a plain snapshot (no $state Proxy artifacts).
expect(JSON.parse(JSON.stringify(saved))).toEqual(saved);
});

it("does NOT save when the branch handler reports no change", async () => {
const cond = new ConditionalCommand();
const saveCommands = vi.fn();

const props = createCommandListProps({
commands: [cond],
app: new App() as never,
plugin: {} as never,
deleteCommand: vi.fn(),
saveCommands,
onEditThenBranch: () => false,
});

const { getByLabelText } = render(CommandList, { props });
await fireEvent.click(getByLabelText("Edit then branch"));
await Promise.resolve();

expect(saveCommands).not.toHaveBeenCalled();
});
});
Loading