diff --git a/src/main/resources/web/browser.js b/src/main/resources/web/browser.js index b512f36..b33ed1b 100644 --- a/src/main/resources/web/browser.js +++ b/src/main/resources/web/browser.js @@ -317,25 +317,36 @@ function dualBrowser() { }, /** - * Cross-panel file-match indicator for row highlighting (v0.3.2). Returns one of: - * 'match-same' — file with the same (name, size, mtime) exists on the - * OTHER panel's current view → subtle green tint - * 'match-mtime-diff' — same (name, size) but different mtime → subtle yellow - * tint (might still be the same content, but copy time - * differed; hash check would settle it definitively) - * null — no useful match, no highlight + * Cross-panel match indicator for row highlighting. Returns one of: + * 'match-same' — file: same (name, size, mtime) exists on the OTHER + * panel's current view → subtle green + * 'match-mtime-diff' — file: same (name, size) but different mtime → subtle + * yellow (might be the same content with drifted mtime) + * — dir: a directory with the same name exists on the + * other side → subtle yellow (we can't compare recursive + * size/mtime cheaply, so name-only is the strongest + * signal we offer here; v0.3.3+) + * null — no useful match * - * Only same-directory comparison: a file at /share/Movies/big.mkv on the left - * is NOT matched against /incoming/big.mkv on the right unless both panels are - * navigated to the matching directory. Dirs are never highlighted. + * Same-directory comparison only: navigate both panels into the matching folder + * to see tints. Hash-based comparison stays out of scope. * * @param side 'local' | 'peer' — which panel the row belongs to * @param e entry being rendered */ matchClass(side, e) { - if (!e || e.type !== 'file') return null; + if (!e) return null; const other = side === 'local' ? this.peer : this.local; if (!other) return null; + if (e.type === 'dir') { + // Dir mtime is non-recursive (only triggers on add/remove of immediate + // children) and the listing has no recursive size, so any "deeper" check + // would require a fs-walk we don't want to fire on every browse. Yellow + // tint = "a folder with this name exists on the other side; open both + // to compare contents." + return other.dirMatch(e.name) ? 'match-mtime-diff' : null; + } + if (e.type !== 'file') return null; const m = other.fileMatch(e.name); if (!m) return null; if (m.size !== e.size) return null; @@ -610,24 +621,34 @@ function makePanel(side) { return this.entries.length > 0 && this.selection.length === this.entries.length; }, - // Lazy index over file-type entries by name, used by dualBrowser.matchClass - // to highlight cross-panel matches (v0.3.2). Rebuilt on the first lookup - // after this.entries is replaced — `_indexedEntries` is the identity guard. + // Lazy by-type index over the panel's entries, used by dualBrowser.matchClass + // to highlight cross-panel matches. Rebuilt on the first lookup after + // this.entries is replaced — `_indexedEntries` is the identity guard. // O(N) build, O(1) lookups thereafter; a typical render with 200 files in // each panel does one rebuild per panel and 200 lookups per side, so - // sub-millisecond. - _fileIndex: null, + // sub-millisecond. Files and dirs live in separate maps because the match + // rules differ (dirs match by name only, files by name+size+mtime). + _entryIndex: null, _indexedEntries: null, - fileMatch(name) { - if (this._fileIndex == null || this._indexedEntries !== this.entries) { - const idx = new Map(); + _ensureIndex() { + if (this._entryIndex == null || this._indexedEntries !== this.entries) { + const files = new Map(); + const dirs = new Map(); for (const e of this.entries) { - if (e && e.type === 'file') idx.set(e.name, e); + if (!e) continue; + if (e.type === 'file') files.set(e.name, e); + else if (e.type === 'dir') dirs.set(e.name, e); } - this._fileIndex = idx; + this._entryIndex = { files, dirs }; this._indexedEntries = this.entries; } - return this._fileIndex.get(name) || null; + return this._entryIndex; + }, + fileMatch(name) { + return this._ensureIndex().files.get(name) || null; + }, + dirMatch(name) { + return this._ensureIndex().dirs.get(name) || null; }, selectedRelPaths() {