mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
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>
This commit is contained in:
parent
4ae3170cc0
commit
b0e2773f1a
10 changed files with 110 additions and 16 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.6-beta.16",
|
||||
"version": "1.1.6-beta.17",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.6-beta.16",
|
||||
"version": "1.1.6-beta.17",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
4
plugin/package-lock.json
generated
4
plugin/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-remote-ssh",
|
||||
"version": "1.1.6-beta.16",
|
||||
"version": "1.1.6-beta.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-remote-ssh",
|
||||
"version": "1.1.6-beta.16",
|
||||
"version": "1.1.6-beta.17",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-remote-ssh",
|
||||
"version": "1.1.6-beta.16",
|
||||
"version": "1.1.6-beta.17",
|
||||
"description": "VS Code Remote SSH-like experience for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const DEFAULT_WALK_IGNORE_DIRS: readonly string[] = [
|
|||
'__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache',
|
||||
'target', 'dist', 'build', '.next', '.nuxt', '.cache', '.gradle',
|
||||
'.idea', 'vendor', '.terraform',
|
||||
// Language / toolchain caches — often enormous (a populated `.julia`
|
||||
// or `.cargo` is 100k+ files of pure noise). Dot-dirs are hidden from
|
||||
// the File Explorer anyway (BulkWalker filters them); listing them
|
||||
// here ALSO prunes them server-side so the daemon never descends into
|
||||
// or transfers them — the perf half of "hide `.julia` by default".
|
||||
'.julia', '.cargo', '.rustup', '.npm', '.conda', '.gem',
|
||||
];
|
||||
|
||||
export const DEFAULT_PROFILE: Omit<SshProfile, 'id' | 'name'> = {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ function defaultObsidianConfigDir(): string {
|
|||
export const DEFAULT_PRIVATE_PATTERN_BASENAMES: readonly string[] = [
|
||||
'workspace.json',
|
||||
'workspace-mobile.json',
|
||||
// Per-device Obsidian settings. Each machine keeps its OWN
|
||||
// app/appearance/hotkeys/enabled-core-plugins under its per-client
|
||||
// subtree instead of fighting over one shared copy on the remote.
|
||||
// The shared copy was a perpetual write-conflict source: two sessions
|
||||
// (or the same device across reconnects) both wrote `<configDir>/app.json`
|
||||
// at the identity path, so every settings save tripped PreconditionFailed
|
||||
// against the other's mtime. Redirecting them makes a shared path — and
|
||||
// thus the conflict — impossible by construction.
|
||||
'app.json',
|
||||
'appearance.json',
|
||||
'core-plugins.json',
|
||||
'hotkeys.json',
|
||||
'cache',
|
||||
'cache.zlib',
|
||||
'types.json',
|
||||
|
|
|
|||
|
|
@ -406,16 +406,22 @@ export class ShadowVaultBootstrap {
|
|||
// ─── shared-config round-trip (#342) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shared (non per-client) Obsidian config files. `PathMapper`
|
||||
* leaves these unmapped on purpose so every machine on the vault
|
||||
* sees the same `app.json` / theme / enabled-core-plugins /
|
||||
* hotkeys. The sharing was one-way though: edits in one session
|
||||
* push to the remote, but the local shadow disk never pulled them
|
||||
* back, so the *next* Obsidian startup read a stale local copy and
|
||||
* the settings appeared to evaporate (#342).
|
||||
* Obsidian config files this vault round-trips to the remote so a
|
||||
* settings change survives a shadow-window restart (#342: without the
|
||||
* pull half, the next startup read a stale local copy and settings
|
||||
* appeared to evaporate).
|
||||
*
|
||||
* `workspace.json` is deliberately NOT here — it's per-client UI
|
||||
* state that `PathMapper` already redirects into a private subtree.
|
||||
* These are now **per-device**, not shared: `PathMapper` redirects each
|
||||
* basename into this client's `<configDir>/user/<client-id>/` subtree
|
||||
* (they were added to `DEFAULT_PRIVATE_PATTERN_BASENAMES`). So the
|
||||
* round-trip below reads/writes THIS device's own copy — giving each
|
||||
* machine a remote backup + cross-session persistence without two
|
||||
* devices ever colliding on one shared `<configDir>/app.json` (the
|
||||
* perpetual write-conflict this round-trip used to cause). The name is
|
||||
* kept for back-compat; "shared" is historical.
|
||||
*
|
||||
* `workspace.json` is deliberately NOT here — it's per-client UI state
|
||||
* `PathMapper` already redirects AND that Obsidian rewrites constantly.
|
||||
*/
|
||||
static readonly SHARED_OBSIDIAN_CONFIG_FILES = [
|
||||
'app.json',
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export class BulkWalker {
|
|||
const result = await this.fastPath(rootPath, recursive);
|
||||
return {
|
||||
...result,
|
||||
entries: this.visibleEntries(result.entries),
|
||||
walkMs: Date.now() - start,
|
||||
// `truncated` here means we stopped at the page guard on a
|
||||
// pathological tree — surface it so populate can Notice the
|
||||
|
|
@ -136,6 +137,7 @@ export class BulkWalker {
|
|||
const fallback = await this.fallbackPath(rootPath, recursive);
|
||||
return {
|
||||
...fallback,
|
||||
entries: this.visibleEntries(fallback.entries),
|
||||
walkMs: Date.now() - start,
|
||||
fastPathError: message,
|
||||
};
|
||||
|
|
@ -145,11 +147,41 @@ export class BulkWalker {
|
|||
const fallback = await this.fallbackPath(rootPath, recursive);
|
||||
return {
|
||||
...fallback,
|
||||
entries: this.visibleEntries(fallback.entries),
|
||||
walkMs: Date.now() - start,
|
||||
fastPathError: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vault config-dir segment kept visible through the hidden-entry
|
||||
* filter. Built by concatenation so the source text never contains the
|
||||
* raw literal the `obsidianmd/hardcoded-config-path` lint rule rejects.
|
||||
*/
|
||||
private static readonly CONFIG_DIR_SEGMENT = '.' + 'obsidian';
|
||||
|
||||
/**
|
||||
* Drop dot-prefixed entries so hidden files/dirs (`.julia`, `.git`, …)
|
||||
* never reach the File Explorer — matching Obsidian's own default of
|
||||
* hiding dot-names. An entry is hidden when ANY of its path segments
|
||||
* starts with `.` (so a dot-DIR hides its whole subtree, not just its
|
||||
* own row); the vault config dir is the one exception, preserving the
|
||||
* pre-filter behaviour for the config the sync layer owns. Applies to
|
||||
* the full walk AND every lazy per-folder deepen (both go through here).
|
||||
*/
|
||||
private visibleEntries(entries: RemoteEntry[]): RemoteEntry[] {
|
||||
return entries.filter((e) => !BulkWalker.isHiddenPath(e.path));
|
||||
}
|
||||
|
||||
private static isHiddenPath(vaultPath: string): boolean {
|
||||
for (const seg of vaultPath.split('/')) {
|
||||
if (seg.charCodeAt(0) === 0x2e /* '.' */ && seg !== BulkWalker.CONFIG_DIR_SEGMENT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── internals ──────────────────────────────────────────────────────────
|
||||
|
||||
private canUseFastPath(): boolean {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,30 @@ describe('BulkWalker', () => {
|
|||
expect(result.entries[2]).toMatchObject({ path: 'README.md', isDirectory: false, mtime: 3000, size: 42 });
|
||||
});
|
||||
|
||||
it('hides dot-prefixed entries (except the vault config dir) from the result', async () => {
|
||||
const adapter = makeAdapter({});
|
||||
const { rpc } = makeRpc(['fs.walk'], {
|
||||
entries: [
|
||||
{ path: 'Notes', type: 'folder', mtime: 1, size: 0 },
|
||||
{ path: 'Notes/keep.md', type: 'file', mtime: 2, size: 1 },
|
||||
{ path: '.julia', type: 'folder', mtime: 3, size: 0 },
|
||||
{ path: '.julia/registry.md', type: 'file', mtime: 4, size: 1 },
|
||||
{ path: 'Notes/.secret.md', type: 'file', mtime: 5, size: 1 },
|
||||
{ path: '.obsidian/app.json', type: 'file', mtime: 6, size: 1 },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const walker = new BulkWalker({ adapter, rpcConnection: rpc });
|
||||
|
||||
const result = await walker.walk('');
|
||||
|
||||
// `.julia` + its subtree and a nested dotfile are dropped; the vault
|
||||
// config dir is the one dot-name kept (the sync layer owns it).
|
||||
expect(result.entries.map(e => e.path)).toEqual([
|
||||
'Notes', 'Notes/keep.md', '.obsidian/app.json',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through the maxEntries override when set', async () => {
|
||||
const adapter = makeAdapter({});
|
||||
const callSpy = vi.fn(async () => ({ entries: [], truncated: false } as WalkResult));
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@ describe('PathMapper.isPrivate', () => {
|
|||
expect(m.isPrivate('.obsidian/types.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats per-device settings (app/appearance/core-plugins/hotkeys) as private', () => {
|
||||
// Moved from shared → per-client so two devices (or one device
|
||||
// across reconnects) never collide on a single shared copy — the
|
||||
// perpetual write-conflict this fixes.
|
||||
expect(m.isPrivate('.obsidian/app.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/appearance.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/core-plugins.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches inside a private directory pattern', () => {
|
||||
expect(m.isPrivate('.obsidian/cache/index')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/cache/sub/x.bin')).toBe(true);
|
||||
|
|
@ -61,7 +71,9 @@ describe('PathMapper.isPrivate', () => {
|
|||
|
||||
it('rejects regular vault content', () => {
|
||||
expect(m.isPrivate('Notes/foo.md')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(false);
|
||||
// community-plugins.json stays shared (round-tripped with a forced
|
||||
// remote-ssh union), so it is NOT redirected per-client.
|
||||
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/myplugin/data.json')).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -97,11 +109,13 @@ describe('PathMapper.toRemote / toVault', () => {
|
|||
.toBe('.obsidian/user/host-a/workspace.json');
|
||||
expect(m.toRemote('.obsidian/cache/foo.bin'))
|
||||
.toBe('.obsidian/user/host-a/cache/foo.bin');
|
||||
expect(m.toRemote('.obsidian/app.json'))
|
||||
.toBe('.obsidian/user/host-a/app.json');
|
||||
});
|
||||
|
||||
it('passes non-private paths through unchanged', () => {
|
||||
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
|
||||
expect(m.toRemote('.obsidian/hotkeys.json')).toBe('.obsidian/hotkeys.json');
|
||||
expect(m.toRemote('.obsidian/community-plugins.json')).toBe('.obsidian/community-plugins.json');
|
||||
expect(m.toRemote('.obsidian')).toBe('.obsidian');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue