Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ bun run wasm:test # Rust unit tests via wasm-pack

The build output is `main.js` and `styles.css` in the repo root. These are loaded directly by Obsidian from `.obsidian/plugins/breadcrumbs/`.

> **Note:** `main.js`, `styles.css`, `*.map`, `node_modules/`, `coverage/`, `data.json`, and `wasm/pkg/` are git-ignored. Don't read or reference them — check source files under `src/` and `wasm/src/` instead.
> **Note:** `main.js`, `styles.css`, `*.map`, `node_modules/`, `coverage/`, and `data.json` are git-ignored. Don't read or reference them — check source files under `src/` and `wasm/src/` instead.
>
> `wasm/pkg/` is **generated by `wasm-pack` but committed** (so builds don't require a Rust toolchain). Don't hand-edit it — it's regenerated by `bun run wasm:build`, which runs a `wasm:postbuild` step that writes `wasm/pkg/.gitignore` and strips the `eslint-disable` line wasm-bindgen emits in the `.d.ts` files. Reference the bindings type at `wasm/pkg/breadcrumbs_graph_wasm.d.ts` only when needed.

## Architecture

Expand Down
155 changes: 155 additions & 0 deletions notes/perf-cleanup-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Perf + cleanup pass — progress log

Tracks an incremental cleanup/performance pass. **One item per commit.** Resuming a
session? Read this file first, find the next `todo`, do that one item, verify, update
the row, stop.

Full plan rationale lives in the approved plan file (Claude plan
`do-you-suggest-any-agile-kazoo`). Branch: `perf-cleanup`.

## Items

| # | Item | Status | Commit | Notes |
|---|------|--------|--------|-------|
| 1a | Debounce view setting write-backs (TreeView/Matrix/TrailView → `saveSettingsDebounced`) | done | d892ace | build clean, 0 errors |
| 1b | Index date_note period lookups (Map instead of `.find()`, O(n·m)→O(n)) | done | ab8a84f | build clean |
| 1c | Debounce opt-in layout-change rebuild (`main.ts:218` → `rebuildGraphDebounced`) | done | 70fb9f7 | build clean |
| 2a | Remove dead code (`utils/markmap.ts`, commented Traverse import, EdgeToAdd type) | done | ea8364a | build clean |
| 2b | Tighten `any` casts (dataview plugin access, metadataTypeManager) | done | 565fdce | build + eslint clean |
| 2c | Remove dead `all_files.dataview` file-source field + branches | done | 05d5292 | build + 242 tests green |
| 3 | Dedup `validate_edge_field` across 9 explicit builders | done | b9f438c | build + lint + 68 tests green |
| 4 | Tests for date_note builder (guards 1b + week_start) | done | fd88a59 | 9 tests, full suite 221 green |
| 4b | Tests for list_note, folder_note, dataview_note, traverse_note | done | 6af2c6b | +21 tests; suite 242 green |
| 5 | Obsidian community scorecard lint fixes (pre-existing) | done | 2ec09d2 | build + 242 tests green |
| 5b | Strip wasm-bindgen `eslint-disable` from generated `wasm/pkg/*.d.ts` | superseded by 5c | 7e6b043 | over-stripped — exposed full ruleset |
| 5c | Scope generated `.d.ts` eslint-disable via `scripts/wasm-postbuild.mjs` | done | fd554e1 | build clean |

## Notes per item

### 1a — Debounce view setting write-backs
Three view components saved settings to disk on every local `$state` change. Swapped
`void plugin.saveSettings()` → `plugin.saveSettingsDebounced()` (existing 600ms
debouncer, `main.ts:51`) in the writeback `$effect` of:
- `src/components/side_views/TreeView.svelte`
- `src/components/side_views/Matrix.svelte`
- `src/components/page_views/TrailView.svelte` (also dropped the now-needless `untrack`
wrapper — the debouncer reads nothing reactive)

No behavior change beyond coalescing rapid disk writes. Verify: `bun run build`.

### 1b — Index date_note period lookups
`add_period_edges` in `src/graph/builders/explicit/date_note.ts` did
`notes.find(n => n.basename === target_basename)` inside two nested loops
(daily→period and finer→coarser), i.e. O(n·m) string scans per rebuild. Built a
`basename → PeriodNote` Map per period kind once (alongside `period_notes`, keeping the
first occurrence to match prior `.find()` semantics) and replaced both `.find()` calls
with `.get()`. `period_notes` arrays kept for the sequential-next loop. Identical edges.

### 1c — Debounce opt-in layout-change rebuild
When `commands.rebuild_graph.trigger.layout_change` is enabled, the `layout-change`
handler in `src/main.ts` called `void this.rebuildGraph()` directly — a full rebuild on
every CM6 cursor move/scroll. Swapped to `this.rebuildGraphDebounced()` (existing 1500ms
`_rebuild_debouncer`), matching the `else` branch. Default for the trigger is `false`,
so only opt-in users were affected.

### 2a — Remove dead code
- Deleted `src/utils/markmap.ts` (only `export const Markmap = {}`, never imported — the
`Markmap` used in CodeblockMarkmap.svelte comes from the `markmap-view` package).
- Removed commented-out `// import { Traverse }` in `src/api/index.ts`. Left the
`TODO(RUST)` commented method stubs below it (tracked future work).
- Removed commented-out `EdgeToAdd` type + its doc comment in `src/interfaces/graph.ts`.
Zero refs confirmed by grep before deleting.

### 2b — Tighten `any` casts
- `src/external/dataview/index.ts`: replaced the two `(app as any).plugins...` accesses
with a single `PluginRegistry` interface and `app as unknown as PluginRegistry`. Also
switched bracket `["dataview"]` to dot notation (eslint). Dropped the two
`no-explicit-any` eslint-disable comments.
- `src/main.ts` `getMetdataPropertyType`: replaced `(metadataTypeManager as any)` with a
cast to `{ getAssignedWidget(field: string): string }`, dropping the three
unsafe-call/any eslint-disable comments. Still guarded by the existing `in` check.

### 2c — Remove dead all_files.dataview file-source
`get_all_files` has hardcoded `dataview: null` since the Dataview-page file source was
retired, so every `all_files.dataview?.forEach/map` branch in the builders was dead.
Removed the `dataview` field from the `AllFiles` interface + `get_all_files` (`files.ts`)
and the matching `null` in the test `make_all_files`, then deleted the 10 dead branches
across 6 builders (date_note ×3, regex_note ×2, tag_note, dendron_note,
johnny_decimal_note, folder_note). regex_note's `nodes` fallback simplified to
`all_files.obsidian.map(...)`. Dropped the now-unused `IDataview` import in `files.ts`.

**Not touched:** the `dataview-from` codeblock feature (separate system in
`src/codeblocks/dataview_from.ts` + schema) and the live Dataview API used by
`dataview_note` (which keeps its own `IDataview` import). Purely internal; no vault-
visible behavior changes. 242 tests still green.

### 3 — Dedup validate_edge_field
New helper `src/graph/builders/explicit/validate_field.ts` exports `validate_edge_field`
(falsy→`fail(undefined)`, non-string→`invalid_field_value`, not-registered→
`invalid_edge_field`, else `succ(string)`). Replaced the copy-pasted 3-branch blocks in
9 sites across 7 builders: folder, dendron, johnny_decimal, tag (field + sibling),
list (field + neighbour), regex (field), traverse. Removed now-unused `fail`/
`graph_build_fail` imports where the block was their only use.

Behavior notes:
- Error **message** wording was unified to `<field> is not a valid field` (was a mix of
"valid field" / "valid BC field"). Codes unchanged; tests assert only on `.code`.
- traverse_note: dropped a dead `raw || default_field` fallback (raw is already a truthy
string at that point, so default was never reached). Identical behavior.

Verify: `bun run build`, `bunx eslint`, `bun run test tests/graph/builders/` (68 green).

### 4 — date_note builder tests
New `tests/graph/builders/date_note.test.ts` (9 tests). Covers `add_period_edges` (the
1b refactor): period sequential-next edges, finer→coarser `up` via the basename Map
(week→month, month→quarter), absent-target skip, and the daily→period path with the
**week_start** edge case — Sunday 2024-01-07 maps to 2024-W01 under `monday` vs 2024-W02
under `sunday`. Plus the invalid-`default_field` error and `edge_source` tag. Helper
deep-clones `DEFAULT_SETTINGS.explicit_edge_sources.date_note` so every period kind is
present, then applies per-kind overrides. Full suite: 221 green (was 212).

Remaining builders (4b) need extra mocks not in `helpers.ts` yet: folder_note wants a
`getAbstractFileByPath` returning a TFolder tree; dataview_note wants a stub Dataview
api; traverse_note wants `metadataCache.resolvedLinks`.

### 4b — tests for folder/traverse/dataview/list builders
Extended `helpers.ts`: `make_plugin` gained a 4th `app_extra` arg
(`getAbstractFileByPath`, `resolvedLinks`, `cachedRead`, `dataview_pages`), and
`mock_file` gained `listItems` / `links` cache options. Added 4 test files (+21 tests):
- traverse_note (6): DFS over `resolvedLinks`, cycle protection, field errors.
- folder_note (5): folder→sibling edges, recurse on/off, invalid field.
- dataview_note (6): query→page edges, DataArray `{values}` normalization,
missing-plugin + invalid-query errors.
- list_note (4): list-item child edges via `cachedRead` + link resolver, field errors.

Note: `bun run build` type-checks `tests/` too (tsc), so test files must be type-clean;
the project `lint` script only covers `src/`. Full suite: 242 green (was 221).

### 5 — Obsidian community scorecard lint fixes
Addressed the obsidianmd branch review (all pre-existing, not from this pass):
- Unnecessary non-null assertions removed: `dataview_from.ts` (`q[pos.i]!` ×3, `split("|")[0]!`) and `typed_link.ts:97` (`match[1]!`).
- Unused catch bindings `catch (_)` → optional `catch {`: `codeblocks/index.ts`, `regex_note.ts`.
- Static inline styles → CSS class: `DateNoteSetupModal` warning div now styled via the existing `.bc-date-note-setup-warning` class in `src/styles.css` (dropped the `style.cssText` assignment).
- Blanket `/* eslint-disable */` in `MDRC.ts` scoped to the single rule that fires (`@typescript-eslint/no-duplicate-type-constituents`).

Skipped: `wasm/pkg/*.d.ts` (generated + git-ignored). Left out of scope: `obsidianmd/ui/sentence-case` warnings (its suggestions lowercase the ISO/US acronyms) and two pre-existing `import/order` + one `Array<T>` warning.

### 5b — strip generated eslint-disable in wasm/pkg
The community scorecard flagged the blanket `/* eslint-disable */` that wasm-bindgen
emits at the top of `wasm/pkg/*.d.ts`. The project eslint config already ignores
`**/*.d.ts`, but the scorecard runs its own pass that doesn't honour that. These files
are tracked (committed so builds don't require a Rust toolchain) and regenerated by
`wasm-pack`, so the fix lives in the build: a new `wasm:postbuild` script (now shared by
`postwasm:build`/`dev`/`profile`, which previously duplicated the `.gitignore` printf)
strips the `eslint-disable` line via `perl -i`. Applied to the committed files too. The
`/* tslint:disable */` line is left (tslint is dead; not flagged).

### 5c — scope the generated .d.ts eslint-disable (revise 5b)
Stripping the disable (5b) exposed the generated bindings to the scorecard's full
ruleset, which then flagged `no-explicit-any`, `no-unsafe-function-type`, and
`no-misused-new` on the wasm-bindgen output. Reverted that approach: instead of removing
the suppression, scope it. Moved the post-wasm logic into `scripts/wasm-postbuild.mjs`
(`wasm:postbuild` now runs it), which writes `wasm/pkg/.gitignore` and rewrites the
generated `.d.ts` header to a disable scoped to exactly those three rules — satisfying
the "specify rule names" meta-rule while keeping generated code unlinted. Idempotent;
handles wasm-pack's fresh blanket `/* eslint-disable */` and re-runs alike.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
"fmt": "bun run wasm:fmt && bun run prettier --write ./src",
"svelte-check": "svelte-check --tsconfig tsconfig.json --compiler-warnings \"unused-export-let:ignore\"",
"lint": "eslint --max-warnings=0 src/ --no-warn-ignored && bun run wasm:lint",
"wasm:postbuild": "bun scripts/wasm-postbuild.mjs",
"wasm:build": "cd wasm && wasm-pack build --target web",
"postwasm:build": "printf '# README auto-generated by wasm-pack; not needed in repo\\nREADME.md\\n' > wasm/pkg/.gitignore",
"postwasm:build": "bun run wasm:postbuild",
"wasm:dev": "cd wasm && wasm-pack build --dev --target web",
"postwasm:dev": "printf '# README auto-generated by wasm-pack; not needed in repo\\nREADME.md\\n' > wasm/pkg/.gitignore",
"postwasm:dev": "bun run wasm:postbuild",
"wasm:profile": "cd wasm && wasm-pack build --profiling --target web",
"postwasm:profile": "printf '# README auto-generated by wasm-pack; not needed in repo\\nREADME.md\\n' > wasm/pkg/.gitignore",
"postwasm:profile": "bun run wasm:postbuild",
"wasm:fmt": "cd wasm && cargo fmt",
"wasm:lint": "cd wasm && cargo clippy",
"wasm:test": "cd wasm && wasm-pack test --node --features test"
Expand Down
36 changes: 36 additions & 0 deletions scripts/wasm-postbuild.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Post-process wasm-pack output (run by the `wasm:postbuild` npm script).
//
// 1. Write wasm/pkg/.gitignore so the auto-generated README isn't committed.
// 2. Normalize the lint-suppression header in the generated `*.d.ts` files.
// wasm-bindgen emits a blanket `/* eslint-disable */`, which the Obsidian
// community scorecard flags ("specify some rule names"). We replace it with a
// disable scoped to exactly the rules that fire on the generated bindings, so
// the suppression is explicit and the scorecard is satisfied. Idempotent.

import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

const PKG_DIR = "wasm/pkg";

const SCOPED_DISABLE =
"/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-misused-new */";

writeFileSync(
join(PKG_DIR, ".gitignore"),
"# README auto-generated by wasm-pack; not needed in repo\nREADME.md\n",
);

// `/* tslint:disable */` is always line 1 of a wasm-bindgen .d.ts; the scoped
// eslint-disable goes right after it. Matches an existing (blanket or scoped)
// eslint-disable line and replaces it, or inserts one if absent.
const HEADER_RE =
/(\/\* tslint:disable \*\/\n)(?:\/\* eslint-disable[^\n]*\*\/\n)?/;

for (const name of readdirSync(PKG_DIR)) {
if (!name.endsWith(".d.ts")) continue;
const path = join(PKG_DIR, name);
const src = readFileSync(path, "utf8");
if (!HEADER_RE.test(src)) continue;
const next = src.replace(HEADER_RE, `$1${SCOPED_DISABLE}\n`);
if (next !== src) writeFileSync(path, next);
}
1 change: 0 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
build_list_index,
LIST_INDEX_DEFAULT_OPTIONS,
} from "src/commands/list_index";
// import { Traverse } from "src/graph/traverse";
import { active_file_store } from "src/stores/active_file";
import { get } from "svelte/store";
import type { EdgeList } from "wasm/pkg/breadcrumbs_graph_wasm";
Expand Down
6 changes: 4 additions & 2 deletions src/codeblocks/MDRC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { Timer } from "src/utils/timer";
import { mount, unmount } from "svelte";
import { Codeblocks } from ".";

/* eslint-disable */
/* The three components currently share a mount return type, so the union reads
as duplicated; keep it explicit for when they diverge. */
/* eslint-disable @typescript-eslint/no-duplicate-type-constituents */
type SvelteComponent =
| ReturnType<typeof CodeblockTree>
| ReturnType<typeof CodeblockMermaid>
| ReturnType<typeof CodeblockMarkmap>;
/* eslint-enable */
/* eslint-enable @typescript-eslint/no-duplicate-type-constituents */

export class CodeblockMDRC extends MarkdownRenderChild {
source: string;
Expand Down
8 changes: 4 additions & 4 deletions src/codeblocks/dataview_from.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type FilePredicate = (file: TFile) => boolean;
const VALID_EXTENSIONS = new Set(["md", "canvas", "base"]);

function skip_ws(q: string, pos: { i: number }) {
while (pos.i < q.length && /\s/.test(q[pos.i]!)) pos.i++;
while (pos.i < q.length && /\s/.test(q[pos.i])) pos.i++;
}

function consume_keyword(
Expand Down Expand Up @@ -42,7 +42,7 @@ function parse_atom(
// #tag
if (q[pos.i] === "#") {
const start = pos.i;
while (pos.i < q.length && !/[\s)&|]/.test(q[pos.i]!)) pos.i++;
while (pos.i < q.length && !/[\s)&|]/.test(q[pos.i])) pos.i++;
const tag = q.slice(start, pos.i);
return (file) => {
const cache = app.metadataCache.getFileCache(file);
Expand Down Expand Up @@ -74,7 +74,7 @@ function parse_atom(
pos.i += 2;
const start = pos.i;
while (pos.i < q.length && q.slice(pos.i, pos.i + 2) !== "]]") pos.i++;
const link_text = q.slice(start, pos.i).split("|")[0]!.trim();
const link_text = q.slice(start, pos.i).split("|")[0].trim();
if (q.slice(pos.i, pos.i + 2) === "]]") pos.i += 2;
const target = app.metadataCache.getFirstLinkpathDest(
link_text,
Expand All @@ -89,7 +89,7 @@ function parse_atom(
}

// Unknown atom — skip to next whitespace/operator and return false
while (pos.i < q.length && !/[\s)]/.test(q[pos.i]!)) pos.i++;
while (pos.i < q.length && !/[\s)]/.test(q[pos.i])) pos.i++;
return () => false;
}

Expand Down
2 changes: 1 addition & 1 deletion src/codeblocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function postprocess_options(
plugin.app,
source_path,
);
} catch (_) {
} catch {
errors.push({
path: "dataview-from",
code: "invalid_field_value",
Expand Down
2 changes: 1 addition & 1 deletion src/components/page_views/TrailView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
if (is_initial_mount) {
is_initial_mount = false;
} else {
untrack(() => void plugin.saveSettings());
plugin.saveSettingsDebounced();
}
});

Expand Down
2 changes: 1 addition & 1 deletion src/components/side_views/Matrix.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
const matrix_snapshot = $state.snapshot(settings);
untrack(() => {
plugin.settings.views.side.matrix = matrix_snapshot;
void plugin.saveSettings();
plugin.saveSettingsDebounced();
});
if (is_initial_mount) {
is_initial_mount = false;
Expand Down
2 changes: 1 addition & 1 deletion src/components/side_views/TreeView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
const tree_snapshot = $state.snapshot(settings);
untrack(() => {
plugin.settings.views.side.tree = tree_snapshot;
void plugin.saveSettings();
plugin.saveSettingsDebounced();
});
});

Expand Down
17 changes: 11 additions & 6 deletions src/external/dataview/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ interface DataviewApi {
pages: (query?: string, path?: string) => unknown;
}

/** Shape of Obsidian's (untyped) community-plugin registry on `app`. */
interface PluginRegistry {
plugins?: {
plugins?: Record<string, { api?: DataviewApi } | undefined>;
};
}

function get_api(app: App): DataviewApi | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (app as any).plugins?.plugins?.["dataview"]?.api as
| DataviewApi
| undefined;
return (app as unknown as PluginRegistry).plugins?.plugins?.dataview?.api;
}

function is_enabled(app: App): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Boolean((app as any).plugins?.plugins?.["dataview"]);
return Boolean(
(app as unknown as PluginRegistry).plugins?.plugins?.dataview,
);
}

/** `FullIndex.initialized` is set when the vault walk finishes (0.5.x). */
Expand Down
Loading