Commit graph

5 commits

Author SHA1 Message Date
Souta
40ef493c9c fix(config): persist third-party plugin settings across restarts (#342, #429)
Plugin settings were lost on every restart of the shadow vault — and the
real settings on the remote were destroyed in the process.

Root cause is an ordering problem the connect-time pull cannot fix.
Obsidian loads community plugins during startup, and each plugin's
`onload()` calls `Plugin.loadData()` -> reads
`<configDir>/plugins/<id>/data.json` — BEFORE remote-ssh has connected
over SSH and patched the adapter. That read therefore always hits the
real FileSystemAdapter, i.e. the LOCAL shadow disk. We cannot get in
front of it: the SSH connect is async and lands at layout-ready.

Nothing ever wrote that local copy. `saveData()` went through the
patched adapter straight to the remote, so the local file stayed empty
and every restart booted the plugin on DEFAULTS — which the plugin then
saved back, overwriting the good settings on the remote too. The plugin
CODE survived (main.js/manifest.json/styles.css are round-tripped by
PLUGIN_BINARY_FILES), which is exactly the reported asymmetry: "the
plugin is still installed, but its settings are gone".

Two changes:

1. SftpDataAdapter: write-through. A successful `<configDir>/**` write is
   now mirrored onto the local shadow disk with the same bytes that
   landed on the remote. The local disk becomes a warm cache, so the
   startup read sees the settings this device last saved no matter how
   the shadow window was launched (Connect from the source window, or
   reopening the vault directly — the latter never runs preSpawnPull).
   Best-effort: a mirror failure never fails the remote write. The NOTE
   tree stays virtual — only the configDir is mirrored.

2. PathMapper: `plugins/*/data.json` is now per-device, redirected into
   `<configDir>/user/<clientId>/`. `saveData()` fires on every settings
   change, so a shared remote path would reintroduce the perpetual
   write-conflict that the four core config files were redirected to
   escape (b0e2773). Deliberately `plugins/*/data.json` and NOT
   `plugins` — the plugin's CODE stays SHARED at the identity path so a
   plugin installed on one machine still loads on every other. This
   required teaching the pattern matcher a `*` segment (exactly one
   path segment, never across `/`), and making crossing-point detection
   wildcard-aware so listing a plugin's own dir merges in this client's
   private data.json.

Multi-device is robust by construction: two devices can no longer
collide on one remote settings file.

Tests: new PathMapper cases pinning settings-private/code-shared and the
`*`-does-not-over-match boundary; new SftpDataAdapter cases pinning the
mirror, the per-device remote path, that notes are NOT mirrored, and that
a mirror failure does not fail the write. Full suite green (1223 passed).
2026-07-14 14:24:26 +09:00
Souta
1d75cf911b fix(review): address /review-pr findings on #450 — preSpawnPull bypass + dotfile leak + misleading Notice
6-dimension multi-agent review surfaced one CRITICAL and several correctness/
clarity issues in the per-device-config + hide-dotfiles change:

CRITICAL — preSpawnPull bypassed PathMapper (main.ts). The pre-spawn config pull
built `toRemote` by joining the remote base only, so it read the OLD shared
identity path `<configDir>/app.json` and clobbered the freshly-redirected
per-device config on EVERY shadow-window spawn — silently undoing the fix. Now it
applies the same `PathMapper.toRemote` the shadow's adapter uses (from
`resolveClientId(settings)` + configDir), so the four config files pull from this
client's `user/<id>/` subtree; identity for non-private paths keeps
community-plugins.json / plugin binaries shared, unchanged.

MEDIUM — cross-client leak + configDir hardcode (BulkWalker). The dotfile filter
excepted a hard-coded `.obsidian`, which (a) drifted from the configurable
`app.vault.configDir` and (b) let every other client's `.obsidian/user/<id>/*`
private state (incl the 4 newly-redirected files) materialize as editable files
in the File Explorer. Dropped the exception — config is loaded off the local
shadow disk, never from the walked model, so hiding the whole `.obsidian` subtree
costs nothing and isolates foreign clients. `isHiddenPath` simplifies to the
codebase's `startsWith('.')` / `.some()` idiom.

HIGH — misleading "0 files" Notice (main.ts + BulkWalker). An all-dotfile remote
filtered to zero entries and fired "0 files found — check remotePath", the wrong
remediation. BulkWalkResult now carries `hiddenCount`; populate distinguishes
"0 visible, N hidden" with an accurate message and logs the count.

LOW — user-facing Notices + a comment said "shared-config" for files that are now
per-device; corrected. (Constant/method rename of SHARED_OBSIDIAN_CONFIG_FILES,
and a legacy→per-client one-time settings migration, are deferred — see PR notes.)

Tests: BulkWalker dotfile test updated (config dir now dropped) + fallback-path
filter test + hiddenCount assertions; PathMapper cross-machine isolation test;
stale restart-roundtrip comment corrected. tsc/lint clean; vitest 1206 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:10:37 +09:00
Souta
b0e2773f1a fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles
Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems:

1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause
   (read from the code, not guessed): a conflict fires when the read cache's
   mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon
   compares mtime as UnixMilli on both sides, so a single device's sequential
   note edits can't perpetually conflict. What CAN: app.json / appearance.json /
   core-plugins.json / hotkeys.json were the only config files NOT redirected
   per-client — they sat at the shared identity path, so two sessions (or one
   device across reconnects) both wrote the SAME remote file and every settings
   save tripped PreconditionFailed on the other's mtime.

   Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES
   so PathMapper redirects each into this client's `<configDir>/user/<id>/`
   subtree (exactly what workspace.json/graph/cache already do). No shared path
   → the conflict is impossible by construction. The existing shared-config
   round-trip keeps working — now on the per-client path, so each machine gets
   its own remote backup + cross-session persistence. This is the user's own
   ask: "config should be settable per accessing device".

2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now:
   - BulkWalker drops dot-prefixed entries from every walk result (full walk AND
     each lazy per-folder deepen), keeping only the vault config dir — matching
     Obsidian's own default of hiding dot-names. A dot-DIR hides its whole
     subtree, not just its row.
   - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so
     the daemon prunes these huge caches server-side (never walked/transferred),
     the perf half of "hide `.julia` by default".

tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a
BulkWalker dotfile-filter test; config round-trip integration tests still green
because seed-write and pull-read go through the same PathMapper). Ships beta.17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:41:35 +09:00
Souta
ceb3356242 feat(plugin): expose clientId + userName in settings (F19/F20)
PathMapper currently derives clientId from os.hostname(), which is
fine for the single-machine case but unreadable when multiple
clients share a remote vault. Surface two settings so users can
pick a friendly identity per device:

- clientId — overrides the per-client subtree name on the remote
  (.obsidian/user/<id>/...). Empty falls back to defaultClientId().
  Inputs are sanitised before save so a typo can't produce an
  invalid path. Changing leaves the old subtree behind; a one-line
  description in the UI flags that.
- userName — display-only for now; surfaces in connect notices and
  is reserved for the future multi-client presence file. Empty
  falls back to the OS username.

Both fields live under a new "This device" section in the
SettingsTab. main.ts reads them through a new resolveClientId()
that's used at adapter-patch time.

defaultUserName() helper added next to defaultClientId() in
PathMapper.ts.

Tests: 2 new sanity cases (defaultClientId is round-trip-safe under
sanitize, defaultUserName is non-empty); 195 total green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:46:53 +09:00
Souta
2827823879 feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.

src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
  cache, cache.zlib, types.json, file-recovery.json, graph.json,
  canvas.json. Errors on the side of "private" — the cost of an
  unnecessary per-machine copy is low; the cost of a shared
  workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
  the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
  more private patterns but not itself private. Today this matches
  exactly `.obsidian` — listing it requires merging the shared
  remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
  subtrees are preserved unchanged on the way back so the caller
  can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
  { primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
  unsafe characters replaced by hyphens; falls back to "unknown"
  when sanitization yields an empty string.

src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
  `toRemote()` first runs the vault path through the mapper, then
  joins with `remoteBasePath` as before. A new private `joinRemote`
  exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
  always lists the primary path (and prunes the `user/` directory
  on a crossing-point listing), then optionally lists the user
  subtree and merges the entries. User-subtree entries take
  precedence on name conflicts so private files always show up
  under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
  what the mapper decided without going through a real
  RemoteFsClient.

src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
  and passes it to the adapter on every patch. The clientId is
  logged so diagnostics in console.log show which subtree the
  current session writes into.

Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
  prefix/sibling distinction, isCrossingPoint, toRemote/toVault
  round-trip, foreign-client subtree pass-through, resolveListing
  for `.obsidian` (merge with hidden user/), private-dir
  redirection, ordinary path pass-through, custom-pattern
  override, and the sanitizeClientId helper for the canonical
  cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
  characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
  block (5 cases): writes to private paths land in the per-client
  subtree, reads come back from there, non-private vault content is
  not redirected, `.obsidian` listing merges shared + per-client
  while hiding the user/ dir, and a private-directory listing
  walks the per-client subtree.

Verification
- cd plugin && npx tsc --noEmit            clean
- cd plugin && npm test                    151 / 151 pass (28 new)
- cd plugin && npm run build:full          all green; bundle 386 KB
                                           (+1 KB over Phase 5-D.5).

Follow-ups
- Settings field for explicit clientId overrides (multi-instance
  setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
  subtree on first connect so Obsidian's first read of
  workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
  the next big features.
2026-04-25 18:07:29 +09:00