From f2256c6eea89413d802a6d670493e8484eeb9a47 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Tue, 28 Apr 2026 16:40:39 +0300 Subject: [PATCH 01/55] feat: folder compare view with Off/Auto/On modes Side-by-side diff highlighting on the dual-pane browser. Each row in both panes is color-coded by its diff status (different, remote-only, local-only, identical). Comparison pairs entries by name AND type, so a local file and a remote folder of the same name are not matched. Three-mode toggle in a new toolbar at the top: Off - no comparison Auto - compares automatically when both panes resolve to the same DW-relative path tail (e.g. local .../Files/Templates and remote /Files/Templates both resolve to /files/templates) On - always compare regardless of paths The toolbar also shows clickable summary pills with per-status counts that toggle which statuses get highlighted in the file lists. Defaults: mode=Auto, highlighted=[different, remote-only]. Both compareMode and highlightedStatuses persist to localStorage. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 149 ++++++++++++++++++ .../src/components/DualPaneBrowser.tsx | 45 +++++- .../src/renderer/src/components/FileList.tsx | 35 +++- .../src/renderer/src/stores/fileStore.ts | 53 ++++++- .../src/renderer/src/utils/compareEntries.ts | 81 ++++++++++ dw-desktop/src/shared/types.ts | 4 + 6 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 dw-desktop/src/renderer/src/components/CompareToolbar.tsx create mode 100644 dw-desktop/src/renderer/src/utils/compareEntries.ts diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx new file mode 100644 index 0000000..9589888 --- /dev/null +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -0,0 +1,149 @@ +import type { CompareMode, DiffStatus } from '../../../shared/types' +import { countByStatus } from '../utils/compareEntries' + +interface CompareToolbarProps { + mode: CompareMode + onModeChange: (mode: CompareMode) => void + diffMap: Map + highlightedStatuses: DiffStatus[] + onToggleStatus: (status: DiffStatus) => void +} + +const MODES: { value: CompareMode; label: string }[] = [ + { value: 'off', label: 'Off' }, + { value: 'auto', label: 'Auto' }, + { value: 'on', label: 'On' } +] + +const STATUSES: DiffStatus[] = ['different', 'remote-only', 'local-only', 'identical'] + +const PILL_COLOR: Record = { + different: 'var(--danger)', + 'remote-only': 'var(--accent-cool)', + 'local-only': 'var(--warning)', + identical: 'var(--success)' +} + +const LABEL: Record = { + different: 'different', + 'remote-only': 'remote only', + 'local-only': 'local only', + identical: 'identical' +} + +export default function CompareToolbar({ + mode, + onModeChange, + diffMap, + highlightedStatuses, + onToggleStatus +}: CompareToolbarProps): React.JSX.Element { + const counts = countByStatus(diffMap) + const hasDiff = diffMap.size > 0 + + return ( +
+ + Compare + +
+ {MODES.map((m, i) => { + const active = mode === m.value + return ( + + ) + })} +
+ + {hasDiff && ( +
+ {STATUSES.map((status) => { + const count = counts[status] + if (count === 0) return null + const active = highlightedStatuses.includes(status) + const color = PILL_COLOR[status] + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 9f7c411..ae72e79 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -5,7 +5,9 @@ import { useEnvStore } from '../stores/envStore' import { useFileStore } from '../stores/fileStore' import { useToastStore } from '../stores/toastStore' import { useTransferStore } from '../stores/transferStore' +import { compareEntries, getDwRelativeTail } from '../utils/compareEntries' import AddEnvModal from './AddEnvModal' +import CompareToolbar from './CompareToolbar' import ContextMenu from './ContextMenu' import FileList from './FileList' import PaneHeader from './PaneHeader' @@ -56,9 +58,15 @@ export default function DualPaneBrowser(): React.JSX.Element { localEntries, localPath, selected, + compareMode, + diffMap, + highlightedStatuses, loadRemote, loadLocal, - setSelected + setSelected, + setCompareMode, + setDiffMap, + toggleHighlightedStatus } = useFileStore() const { addJob } = useTransferStore() const showToast = useToastStore((s) => s.show) @@ -151,6 +159,26 @@ export default function DualPaneBrowser(): React.JSX.Element { setConflictCount(conflicts.length) }, [selected, remoteEntries]) + useEffect(() => { + if (compareMode === 'off') { + setDiffMap(new Map()) + return + } + if (compareMode === 'on') { + setDiffMap(compareEntries(localEntries, remoteEntries)) + return + } + // auto: only compare when DW-relative path tails match (e.g. local .../Files/Templates + // and remote /Files/Templates both resolve to /files/templates) + const localTail = getDwRelativeTail(localPath) + const remoteTail = getDwRelativeTail(toDisplayRemotePath(remotePath)) + if (localTail && remoteTail && localTail === remoteTail) { + setDiffMap(compareEntries(localEntries, remoteEntries)) + } else { + setDiffMap(new Map()) + } + }, [compareMode, localEntries, remoteEntries, localPath, remotePath, setDiffMap]) + async function navigateLocal(entry: FileEntry): Promise { if (entry.type !== 'directory') return setLocalForwardStack([]) @@ -280,6 +308,16 @@ export default function DualPaneBrowser(): React.JSX.Element { } return ( +
+ {activeEnv && ( + + )}
{/* Local pane */}
setContextMenu({ entry, x, y, pane: 'local' })} onDoubleClick={(entry) => void navigateLocal(entry)} @@ -502,8 +542,10 @@ export default function DualPaneBrowser(): React.JSX.Element { } /> setContextMenu({ entry, x, y, pane: 'remote' })} onDoubleClick={(entry) => void navigateRemote(entry)} @@ -530,6 +572,7 @@ export default function DualPaneBrowser(): React.JSX.Element { )}
+
{/* Context menu */} {contextMenu && (() => { diff --git a/dw-desktop/src/renderer/src/components/FileList.tsx b/dw-desktop/src/renderer/src/components/FileList.tsx index 8ea7a46..cf00916 100644 --- a/dw-desktop/src/renderer/src/components/FileList.tsx +++ b/dw-desktop/src/renderer/src/components/FileList.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import type { FileEntry } from '../../../shared/types' +import type { DiffStatus, FileEntry } from '../../../shared/types' +import { diffKey } from '../utils/compareEntries' interface FileListProps { entries: FileEntry[] @@ -12,6 +13,22 @@ interface FileListProps { onDropOnPane?: (paths: string[]) => void dropTarget?: boolean pane?: 'local' | 'remote' + diffMap?: Map + highlightedStatuses?: DiffStatus[] +} + +const DIFF_BORDER: Record = { + 'local-only': 'var(--warning)', + 'remote-only': 'var(--accent-cool)', + different: 'var(--danger)', + identical: 'var(--success)' +} + +const DIFF_BG: Record = { + 'local-only': 'color-mix(in srgb, var(--warning) 10%, transparent)', + 'remote-only': 'color-mix(in srgb, var(--accent-cool) 10%, transparent)', + different: 'color-mix(in srgb, var(--danger) 10%, transparent)', + identical: 'color-mix(in srgb, var(--success) 6%, transparent)' } function FolderIcon({ color }: { color: string }): React.JSX.Element { @@ -86,7 +103,9 @@ export default function FileList({ onDropIntoDir, onDropOnPane, dropTarget, - pane + pane, + diffMap, + highlightedStatuses }: FileListProps): React.JSX.Element { const isRemote = pane === 'remote' const [dragOverPath, setDragOverPath] = useState(null) @@ -189,6 +208,11 @@ export default function FileList({ const isSelected = selected.includes(entry.path) const isDragOver = dropTarget && dragOverPath === entry.path && entry.type === 'directory' const isDragging = draggingPaths.includes(entry.path) + const status = diffMap?.get(diffKey(entry)) + const highlight = + status && (highlightedStatuses?.includes(status) ?? false) ? status : undefined + const diffBorder = highlight ? DIFF_BORDER[highlight] : 'transparent' + const diffBg = highlight ? DIFF_BG[highlight] : undefined return (
{ - if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent' + if (!isSelected) (e.currentTarget as HTMLElement).style.background = diffBg ?? 'transparent' }} > diff --git a/dw-desktop/src/renderer/src/stores/fileStore.ts b/dw-desktop/src/renderer/src/stores/fileStore.ts index 27bf48a..4791f32 100644 --- a/dw-desktop/src/renderer/src/stores/fileStore.ts +++ b/dw-desktop/src/renderer/src/stores/fileStore.ts @@ -1,5 +1,29 @@ import { create } from 'zustand' -import type { FileEntry } from '../../../shared/types' +import type { CompareMode, DiffStatus, FileEntry } from '../../../shared/types' + +const COMPARE_MODE_KEY = 'dw.compareMode' +const HIGHLIGHTED_STATUSES_KEY = 'dw.highlightedStatuses' +const DEFAULT_HIGHLIGHTED: DiffStatus[] = ['different', 'remote-only'] + +function loadCompareMode(): CompareMode { + const v = localStorage.getItem(COMPARE_MODE_KEY) + return v === 'off' || v === 'on' ? v : 'auto' +} + +function loadHighlighted(): DiffStatus[] { + try { + const raw = localStorage.getItem(HIGHLIGHTED_STATUSES_KEY) + if (raw) { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) return parsed.filter((s): s is DiffStatus => + s === 'local-only' || s === 'remote-only' || s === 'different' || s === 'identical' + ) + } + } catch { + // ignore + } + return DEFAULT_HIGHLIGHTED +} interface FileState { remoteEntries: FileEntry[] @@ -8,9 +32,15 @@ interface FileState { localEntries: FileEntry[] localPath: string selected: { pane: 'local' | 'remote'; paths: string[] } + compareMode: CompareMode + diffMap: Map + highlightedStatuses: DiffStatus[] loadRemote: (envName: string, path: string) => Promise loadLocal: (path: string, envName?: string | null) => Promise setSelected: (pane: 'local' | 'remote', paths: string[]) => void + setCompareMode: (mode: CompareMode) => void + setDiffMap: (map: Map) => void + toggleHighlightedStatus: (status: DiffStatus) => void } function persistRemote(envName: string, remotePath: string): void { @@ -28,6 +58,9 @@ export const useFileStore = create((set, get) => ({ localEntries: [], localPath: '', selected: { pane: 'local', paths: [] }, + compareMode: loadCompareMode(), + diffMap: new Map(), + highlightedStatuses: loadHighlighted(), loadRemote: async (envName, path) => { const result = await window.dw.files.list(envName, path) @@ -48,5 +81,23 @@ export const useFileStore = create((set, get) => ({ setSelected: (pane, paths) => { set({ selected: { pane, paths } }) + }, + + setCompareMode: (mode) => { + localStorage.setItem(COMPARE_MODE_KEY, mode) + set({ compareMode: mode }) + }, + + setDiffMap: (map) => { + set({ diffMap: map }) + }, + + toggleHighlightedStatus: (status) => { + const current = get().highlightedStatuses + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status] + localStorage.setItem(HIGHLIGHTED_STATUSES_KEY, JSON.stringify(next)) + set({ highlightedStatuses: next }) } })) diff --git a/dw-desktop/src/renderer/src/utils/compareEntries.ts b/dw-desktop/src/renderer/src/utils/compareEntries.ts new file mode 100644 index 0000000..519060b --- /dev/null +++ b/dw-desktop/src/renderer/src/utils/compareEntries.ts @@ -0,0 +1,81 @@ +import type { DiffStatus, FileEntry } from '../../../shared/types' + +/** + * Builds the diff-map key for an entry. Pairs file-to-file and folder-to-folder + * only — a local file `config` and a remote folder `config` will not match. + */ +export function diffKey(entry: FileEntry): string { + return `${entry.type}:${entry.name.toLowerCase()}` +} + +/** + * Compares local and remote entries. Returns a map keyed by `${type}:${nameLowercase}` + * so callers can look up the status of any entry on either side with diffKey(entry). + * + * Matching is by (name, type). For matched files, equality is based on size and + * modified timestamp. Directories are always 'identical' when both sides have a + * folder of the same name (recursive comparison is out of scope here). + */ +export function compareEntries( + local: FileEntry[], + remote: FileEntry[] +): Map { + const result = new Map() + const remoteByKey = new Map() + for (const e of remote) remoteByKey.set(diffKey(e), e) + + const seen = new Set() + for (const l of local) { + const key = diffKey(l) + seen.add(key) + const r = remoteByKey.get(key) + if (!r) { + result.set(key, 'local-only') + continue + } + if (l.type === 'directory') { + result.set(key, 'identical') + continue + } + const sizeMatch = l.size === r.size + const mtimeMatch = !!l.modified && !!r.modified && l.modified === r.modified + result.set(key, sizeMatch && mtimeMatch ? 'identical' : 'different') + } + + for (const [key] of remoteByKey) { + if (!seen.has(key)) result.set(key, 'remote-only') + } + + return result +} + +export function countByStatus(diff: Map): Record { + const counts: Record = { + 'local-only': 0, + 'remote-only': 0, + different: 0, + identical: 0 + } + for (const v of diff.values()) counts[v]++ + return counts +} + +/** + * Extracts the DW-relative path tail starting at a `/files` segment (case-insensitive). + * Returns null if no `/files` path segment exists. + * + * Match rules: `files` must be a complete path segment, not a substring of one + * (so `myfiles` does NOT match). + * + * "C:\\Projects\\Client\\Files\\Templates" → "/files/templates" + * "/Files/Templates" → "/files/templates" + * "C:\\MyFiles\\stuff" → null + * "C:\\Downloads" → null + */ +export function getDwRelativeTail(p: string): string | null { + if (!p) return null + const norm = p.replace(/\\/g, '/').toLowerCase() + const m = norm.match(/(?:^|\/)files(\/.*)?$/) + if (!m) return null + return '/files' + (m[1] ?? '') +} diff --git a/dw-desktop/src/shared/types.ts b/dw-desktop/src/shared/types.ts index 8973770..d7aa68a 100644 --- a/dw-desktop/src/shared/types.ts +++ b/dw-desktop/src/shared/types.ts @@ -36,6 +36,10 @@ export interface FileEntry { modified?: string } +export type DiffStatus = 'local-only' | 'remote-only' | 'different' | 'identical' + +export type CompareMode = 'off' | 'auto' | 'on' + export interface TransferJob { id: string batchId: string From 5a69c094c9d9f692e9a84015f9a7034dd1e3145b Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Tue, 28 Apr 2026 16:47:31 +0300 Subject: [PATCH 02/55] feat: navigate above the drive root to a Drives view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the local pane could not go higher than C:\Users\... because walking up from C:\ produced an unlistable path. Now the up-arrow, breadcrumb, and back-button keep stepping until they reach an empty "Drives" path that lists every available drive (A:\ through Z:\) on Windows. Each drive renders as a directory entry and double-click / drag-drop work as on any other folder. Breadcrumb now also shows a leading "Drives" crumb on Windows so the user can jump back to the drive list from any depth in one click. The drives-view path is treated as transient — it is not persisted as the last-visited folder. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/main/ipc/files.ts | 27 ++++++++++ .../src/components/DualPaneBrowser.tsx | 22 ++++++-- .../renderer/src/components/PaneHeader.tsx | 50 +++++++++++++++++-- .../src/renderer/src/stores/fileStore.ts | 4 +- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/dw-desktop/src/main/ipc/files.ts b/dw-desktop/src/main/ipc/files.ts index a8b7d0b..cd6c4ee 100644 --- a/dw-desktop/src/main/ipc/files.ts +++ b/dw-desktop/src/main/ipc/files.ts @@ -35,6 +35,26 @@ async function listLocalEntries(dirPath: string): Promise { }) } +/** + * Enumerates available drives on Windows (A:\ through Z:\). Detects each by + * attempting `stat()` on its root in parallel. Empty array on non-Windows. + */ +async function listWindowsDrives(): Promise { + if (process.platform !== 'win32') return [] + const checks: Promise[] = [] + for (let code = 'A'.charCodeAt(0); code <= 'Z'.charCodeAt(0); code++) { + const letter = String.fromCharCode(code) + const root = `${letter}:\\` + checks.push( + stat(root) + .then(() => ({ name: `${letter}:`, path: root, type: 'directory' as const })) + .catch(() => null) + ) + } + const results = await Promise.all(checks) + return results.filter((d): d is FileEntry => d !== null) +} + export function registerFileHandlers(): void { ipcMain.handle('files:list', async (_event, { envName, path }: { envName: string; path: string }) => { const result = getEnvOrError(envName) @@ -108,6 +128,13 @@ export function registerFileHandlers(): void { ipcMain.handle('fs:list', async (_event, { dirPath }: { dirPath: string }): Promise> => { try { + // Empty path = "above the drive root" view (Windows drives or Unix /). + if (!dirPath) { + if (process.platform === 'win32') { + return { ok: true, data: await listWindowsDrives() } + } + return { ok: true, data: await listLocalEntries('/') } + } return { ok: true, data: await listLocalEntries(dirPath) } } catch (err) { return { ok: false, error: (err as Error).message } diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index ae72e79..41cee88 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -42,11 +42,25 @@ function parentPath(path: string): string { } function localParentPath(path: string): string { + if (!path) return '' + // Windows drive root (C:\, C:/, or C:) → step up to the drives view. + if (/^[A-Za-z]:[\\/]?$/.test(path)) return '' + // Unix root has no parent. + if (path === '/') return '/' + const sep = path.includes('\\') ? '\\' : '/' - const parts = path.split(sep) - if (parts.length <= 1) return path - const parent = parts.slice(0, -1).join(sep) - return parent || path + const parts = path.split(sep).filter(Boolean) + parts.pop() + + if (parts.length === 0) { + // Walked off a Unix path → root. Walked off a relative path → drives. + return sep === '/' ? '/' : '' + } + // Drive letter alone needs a trailing separator to be listable. + if (parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) { + return parts[0] + sep + } + return sep === '/' ? '/' + parts.join('/') : parts.join(sep) } export default function DualPaneBrowser(): React.JSX.Element { diff --git a/dw-desktop/src/renderer/src/components/PaneHeader.tsx b/dw-desktop/src/renderer/src/components/PaneHeader.tsx index e2da708..6cd58bd 100644 --- a/dw-desktop/src/renderer/src/components/PaneHeader.tsx +++ b/dw-desktop/src/renderer/src/components/PaneHeader.tsx @@ -9,14 +9,35 @@ interface PaneHeaderProps { } function Breadcrumb({ path, onNavigateTo }: { path: string; onNavigateTo: (path: string) => void }): React.JSX.Element { - const isWindows = path.includes('\\') + const isWindows = path.includes('\\') || /^[A-Za-z]:/.test(path) const sep = isWindows ? '\\' : '/' - const parts = path.replace(/[/\\]$/, '').split(sep).filter(Boolean) + const parts = path.replace(/[/\\]$/, '').split(/[\\/]/).filter(Boolean) function segmentPath(i: number): string { const joined = parts.slice(0, i + 1).join(sep) - // Windows: "C:\Users" — no leading sep. Unix: "/Files/..." — leading sep needed. - return isWindows ? joined : '/' + joined + if (isWindows) { + // A bare drive letter (C:) needs a trailing separator to be a valid root path. + if (i === 0 && /^[A-Za-z]:$/.test(joined)) return joined + sep + return joined + } + return '/' + joined + } + + // Empty path = drives view. Show a static "Drives" label. + if (parts.length === 0) { + return ( + + Drives + + ) } return ( @@ -32,6 +53,27 @@ function Breadcrumb({ path, onNavigateTo }: { path: string; onNavigateTo: (path: whiteSpace: 'nowrap' }} > + {/* On Windows, a leading "Drives" crumb returns to the drive list. */} + {isWindows && ( + + + + )} {parts.map((part, i) => { const isLast = i === parts.length - 1 return ( diff --git a/dw-desktop/src/renderer/src/stores/fileStore.ts b/dw-desktop/src/renderer/src/stores/fileStore.ts index 4791f32..68cfcd7 100644 --- a/dw-desktop/src/renderer/src/stores/fileStore.ts +++ b/dw-desktop/src/renderer/src/stores/fileStore.ts @@ -75,7 +75,9 @@ export const useFileStore = create((set, get) => ({ if (result.ok) { set({ localEntries: result.data ?? [], localPath: path }) const targetEnv = envName === undefined ? get().remoteEnvName : envName - if (targetEnv) persistLocal(targetEnv, path) + // Skip persisting the empty (drives-view) path — it's transient navigation, + // not a meaningful "last folder" to restore on next open. + if (targetEnv && path) persistLocal(targetEnv, path) } }, From 65e49b08d695ed5faae846a41b8837a9c67301d5 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Tue, 28 Apr 2026 16:49:46 +0300 Subject: [PATCH 03/55] fix: tolerate stat() failures on protected entries when listing a directory Listing C:\ failed because stat() rejects on protected entries like System Volume Information and $Recycle.Bin. Promise.all over the dirents then rejected the whole call. Per-entry try/catch falls back to dirent-only info (name, path, type) when stat() fails, so a single inaccessible item no longer blocks the listing. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/main/ipc/files.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/dw-desktop/src/main/ipc/files.ts b/dw-desktop/src/main/ipc/files.ts index cd6c4ee..84c3cc7 100644 --- a/dw-desktop/src/main/ipc/files.ts +++ b/dw-desktop/src/main/ipc/files.ts @@ -19,13 +19,21 @@ async function listLocalEntries(dirPath: string): Promise { const entries = await Promise.all( children.map(async (child) => { const childPath = join(dirPath, child.name) - const childStat = await stat(childPath) - return { - name: child.name, - path: childPath, - type: (child.isDirectory() ? 'directory' : 'file') as 'file' | 'directory', - size: childStat.isFile() ? childStat.size : undefined, - modified: childStat.mtime.toISOString() + const type: 'file' | 'directory' = child.isDirectory() ? 'directory' : 'file' + // stat() can fail on protected entries at the drive root (System Volume + // Information, $Recycle.Bin, pagefile.sys, etc). Fall back to dirent info + // so a single inaccessible entry doesn't break the whole listing. + try { + const childStat = await stat(childPath) + return { + name: child.name, + path: childPath, + type, + size: childStat.isFile() ? childStat.size : undefined, + modified: childStat.mtime.toISOString() + } + } catch { + return { name: child.name, path: childPath, type } } }) ) From d5086afa5e36970c97b3f1ba050f9c1e8d4ade9d Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Tue, 28 Apr 2026 16:57:36 +0300 Subject: [PATCH 04/55] change: back/forward buttons follow visit history instead of walking up Previously the back mouse button (button 3) navigated to the parent of the current folder, regardless of where the user actually came from. Clicking a breadcrumb deep in the tree and then pressing back stepped up the parent chain, not back to the prior folder. Replace the forward-only stack with a proper back+forward history pair per pane. User-initiated navigation (up arrow, breadcrumb click, double-click into a folder) now pushes the previous path onto the back stack and clears forward; back pops from back onto forward; forward pops from forward onto back. Refresh does not touch history. Mouse back/forward buttons are no-ops when their respective stack is empty. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DualPaneBrowser.tsx | 91 ++++++++++++++++--- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 41cee88..fad037b 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -87,7 +87,9 @@ export default function DualPaneBrowser(): React.JSX.Element { const [remoteLoading, setRemoteLoading] = useState(false) const [localLoading, setLocalLoading] = useState(false) + const [localBackStack, setLocalBackStack] = useState([]) const [localForwardStack, setLocalForwardStack] = useState([]) + const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) const [contextMenu, setContextMenu] = useState(null) const [showAddEnv, setShowAddEnv] = useState(false) @@ -193,22 +195,82 @@ export default function DualPaneBrowser(): React.JSX.Element { } }, [compareMode, localEntries, remoteEntries, localPath, remotePath, setDiffMap]) - async function navigateLocal(entry: FileEntry): Promise { - if (entry.type !== 'directory') return + // User-initiated navigation: push the current path onto back, clear forward. + async function navigateLocalTo(path: string): Promise { + if (path === localPath) return + setLocalBackStack((b) => [localPath, ...b]) setLocalForwardStack([]) setLocalLoading(true) - await loadLocal(entry.path) + await loadLocal(path) setLocalLoading(false) } - async function navigateRemote(entry: FileEntry): Promise { - if (!activeEnv || entry.type !== 'directory') return + // Back/forward use the history stacks rather than the parent path. + async function goBackLocal(): Promise { + setLocalBackStack((b) => { + if (b.length === 0) return b + const [prev, ...rest] = b + setLocalForwardStack((f) => [localPath, ...f]) + setLocalLoading(true) + void loadLocal(prev).finally(() => setLocalLoading(false)) + return rest + }) + } + + async function goForwardLocal(): Promise { + setLocalForwardStack((f) => { + if (f.length === 0) return f + const [next, ...rest] = f + setLocalBackStack((b) => [localPath, ...b]) + setLocalLoading(true) + void loadLocal(next).finally(() => setLocalLoading(false)) + return rest + }) + } + + async function navigateRemoteTo(path: string): Promise { + if (!activeEnv || path === remotePath) return + setRemoteBackStack((b) => [remotePath, ...b]) setRemoteForwardStack([]) setRemoteLoading(true) - await loadRemote(activeEnv.name, entry.path) + await loadRemote(activeEnv.name, path) setRemoteLoading(false) } + async function goBackRemote(): Promise { + if (!activeEnv) return + setRemoteBackStack((b) => { + if (b.length === 0) return b + const [prev, ...rest] = b + setRemoteForwardStack((f) => [remotePath, ...f]) + setRemoteLoading(true) + void loadRemote(activeEnv.name, prev).finally(() => setRemoteLoading(false)) + return rest + }) + } + + async function goForwardRemote(): Promise { + if (!activeEnv) return + setRemoteForwardStack((f) => { + if (f.length === 0) return f + const [next, ...rest] = f + setRemoteBackStack((b) => [remotePath, ...b]) + setRemoteLoading(true) + void loadRemote(activeEnv.name, next).finally(() => setRemoteLoading(false)) + return rest + }) + } + + async function navigateLocal(entry: FileEntry): Promise { + if (entry.type !== 'directory') return + await navigateLocalTo(entry.path) + } + + async function navigateRemote(entry: FileEntry): Promise { + if (!activeEnv || entry.type !== 'directory') return + await navigateRemoteTo(entry.path) + } + async function handleUpload(localPaths: string[], targetRemotePath: string): Promise { if (!activeEnv) return if (targetRemotePath === '/' || targetRemotePath === '') { @@ -336,8 +398,8 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Local pane */}
{ - if (e.button === 3) { e.preventDefault(); setLocalForwardStack(f => [localPath, ...f]); void loadLocal(localParentPath(localPath)) } - if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault(); const next = localForwardStack[0]; setLocalForwardStack(f => f.slice(1)); void loadLocal(next) } + if (e.button === 3 && localBackStack.length > 0) { e.preventDefault(); void goBackLocal() } + if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault(); void goForwardLocal() } }} style={{ display: 'flex', @@ -352,9 +414,9 @@ export default function DualPaneBrowser(): React.JSX.Element { { setLocalForwardStack([]); void loadLocal(localParentPath(localPath)) }} + onNavigateUp={() => void navigateLocalTo(localParentPath(localPath))} onRefresh={() => void loadLocal(localPath)} - onNavigateTo={(p) => { setLocalForwardStack([]); void loadLocal(p) }} + onNavigateTo={(p) => void navigateLocalTo(p)} actions={ localSelected.length > 1 ? ( {localSelected.length} selected @@ -481,8 +543,8 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Remote pane */}
{ - if (e.button === 3 && activeEnv) { e.preventDefault(); setRemoteForwardStack(f => [remotePath, ...f]); void loadRemote(activeEnv.name, parentPath(remotePath)) } - if (e.button === 4 && remoteForwardStack.length > 0) { e.preventDefault(); const next = remoteForwardStack[0]; setRemoteForwardStack(f => f.slice(1)); void loadRemote(activeEnv!.name, next) } + if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { e.preventDefault(); void goBackRemote() } + if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { e.preventDefault(); void goForwardRemote() } }} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--pane-bg)' }} > @@ -539,15 +601,14 @@ export default function DualPaneBrowser(): React.JSX.Element { path={toDisplayRemotePath(remotePath)} label={envLabel(activeEnv)} sublabel={activeEnv.host} - onNavigateUp={() => { setRemoteForwardStack([]); void loadRemote(activeEnv.name, parentPath(remotePath)) }} + onNavigateUp={() => void navigateRemoteTo(parentPath(remotePath))} onRefresh={() => { setRemoteLoading(true) void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) }} onNavigateTo={(displayPath) => { const virtual = displayPath.replace(/^\/Files/, '') || '/' - setRemoteForwardStack([]) - void loadRemote(activeEnv.name, virtual) + void navigateRemoteTo(virtual) }} actions={ remoteSelected.length > 1 ? ( From f4ed3b5fee2886c25f0e840733bd7e571fdf4e0d Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Tue, 28 Apr 2026 17:02:23 +0300 Subject: [PATCH 05/55] change: compare by size only, drop modified-timestamp check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mtime check produced too many false-positive "different" flags because local fs.stat().mtime and remote updatedAt diverge by upload time, clock skew between client and server, and local restores from backup or git — even when the content is byte-identical. Drop it. Files are now identical when their sizes match, different otherwise. Same-size edits will slip through, but in template/asset deployments those are rare. False positives across the board are worse than rare false negatives because they train the user to ignore the diff display. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/utils/compareEntries.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dw-desktop/src/renderer/src/utils/compareEntries.ts b/dw-desktop/src/renderer/src/utils/compareEntries.ts index 519060b..c08adda 100644 --- a/dw-desktop/src/renderer/src/utils/compareEntries.ts +++ b/dw-desktop/src/renderer/src/utils/compareEntries.ts @@ -12,9 +12,11 @@ export function diffKey(entry: FileEntry): string { * Compares local and remote entries. Returns a map keyed by `${type}:${nameLowercase}` * so callers can look up the status of any entry on either side with diffKey(entry). * - * Matching is by (name, type). For matched files, equality is based on size and - * modified timestamp. Directories are always 'identical' when both sides have a - * folder of the same name (recursive comparison is out of scope here). + * Matching is by (name, type). For matched files, equality is byte-size only — + * modified timestamps are deliberately ignored because they diverge by upload + * time, clock skew, and local restores even when content is identical, producing + * false-positive "different" flags. Directories that match by name are always + * 'identical' (no recursive comparison). */ export function compareEntries( local: FileEntry[], @@ -37,9 +39,7 @@ export function compareEntries( result.set(key, 'identical') continue } - const sizeMatch = l.size === r.size - const mtimeMatch = !!l.modified && !!r.modified && l.modified === r.modified - result.set(key, sizeMatch && mtimeMatch ? 'identical' : 'different') + result.set(key, l.size === r.size ? 'identical' : 'different') } for (const [key] of remoteByKey) { From 3200a16211886bfb7a1a3b6d509b526e924b8cb3 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:00:58 +0300 Subject: [PATCH 06/55] fix: fall back to homedir when saved local path is unavailable If the persisted localPath no longer exists (deleted folder, disconnected drive), loadLocal now returns false and DualPaneBrowser retries with the user's home directory instead of leaving the pane empty. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx | 6 +++++- dw-desktop/src/renderer/src/stores/fileStore.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index fad037b..0b83163 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -151,7 +151,11 @@ export default function DualPaneBrowser(): React.JSX.Element { if (cancelled) return if (resolvedLocal && resolvedLocal !== useFileStore.getState().localPath) { setLocalLoading(true) - await loadLocal(resolvedLocal, activeEnv.name) + const ok = await loadLocal(resolvedLocal, activeEnv.name) + if (!ok && !cancelled) { + const home = await window.dw.fs.homedir() + if (home && home !== resolvedLocal) await loadLocal(home, activeEnv.name) + } if (!cancelled) setLocalLoading(false) } })().finally(() => { diff --git a/dw-desktop/src/renderer/src/stores/fileStore.ts b/dw-desktop/src/renderer/src/stores/fileStore.ts index 68cfcd7..8cac1b7 100644 --- a/dw-desktop/src/renderer/src/stores/fileStore.ts +++ b/dw-desktop/src/renderer/src/stores/fileStore.ts @@ -36,7 +36,7 @@ interface FileState { diffMap: Map highlightedStatuses: DiffStatus[] loadRemote: (envName: string, path: string) => Promise - loadLocal: (path: string, envName?: string | null) => Promise + loadLocal: (path: string, envName?: string | null) => Promise setSelected: (pane: 'local' | 'remote', paths: string[]) => void setCompareMode: (mode: CompareMode) => void setDiffMap: (map: Map) => void @@ -78,7 +78,9 @@ export const useFileStore = create((set, get) => ({ // Skip persisting the empty (drives-view) path — it's transient navigation, // not a meaningful "last folder" to restore on next open. if (targetEnv && path) persistLocal(targetEnv, path) + return true } + return false }, setSelected: (pane, paths) => { From 1f11a9945019fdb399ec138011ef63b7b11b2c23 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:14:04 +0300 Subject: [PATCH 07/55] =?UTF-8?q?feat:=20sync=20navigation=20=E2=80=94=20n?= =?UTF-8?q?avigate=20both=20panes=20together=20when=20structure=20matches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When compare detects matching folder structures (auto tail-match or manual On), a Sync toggle appears in the CompareToolbar. When enabled, double-clicking a folder that exists on both sides navigates both panes simultaneously. Matched folders are highlighted teal in both panes with a ⇄ icon in the size column to hint that clicking will move both panes. Sync state persists to localStorage. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 35 +++++++++++++++++- .../src/components/DualPaneBrowser.tsx | 37 +++++++++++++++++-- .../src/renderer/src/components/FileList.tsx | 15 ++++++-- .../src/renderer/src/stores/fileStore.ts | 9 +++++ 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 9589888..37110e9 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -7,6 +7,9 @@ interface CompareToolbarProps { diffMap: Map highlightedStatuses: DiffStatus[] onToggleStatus: (status: DiffStatus) => void + pathsInSync: boolean + syncNav: boolean + onSyncNavChange: (v: boolean) => void } const MODES: { value: CompareMode; label: string }[] = [ @@ -36,7 +39,10 @@ export default function CompareToolbar({ onModeChange, diffMap, highlightedStatuses, - onToggleStatus + onToggleStatus, + pathsInSync, + syncNav, + onSyncNavChange }: CompareToolbarProps): React.JSX.Element { const counts = countByStatus(diffMap) const hasDiff = diffMap.size > 0 @@ -98,6 +104,33 @@ export default function CompareToolbar({ })}
+ {pathsInSync && ( + <> + + + + )} + {hasDiff && (
{STATUSES.map((status) => { diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 0b83163..b023cb6 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -1,11 +1,11 @@ import { nanoid } from 'nanoid' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { envLabel, type FileEntry } from '../../../shared/types' import { useEnvStore } from '../stores/envStore' import { useFileStore } from '../stores/fileStore' import { useToastStore } from '../stores/toastStore' import { useTransferStore } from '../stores/transferStore' -import { compareEntries, getDwRelativeTail } from '../utils/compareEntries' +import { compareEntries, diffKey, getDwRelativeTail } from '../utils/compareEntries' import AddEnvModal from './AddEnvModal' import CompareToolbar from './CompareToolbar' import ContextMenu from './ContextMenu' @@ -75,11 +75,13 @@ export default function DualPaneBrowser(): React.JSX.Element { compareMode, diffMap, highlightedStatuses, + syncNav, loadRemote, loadLocal, setSelected, setCompareMode, setDiffMap, + setSyncNav, toggleHighlightedStatus } = useFileStore() const { addJob } = useTransferStore() @@ -91,6 +93,7 @@ export default function DualPaneBrowser(): React.JSX.Element { const [localForwardStack, setLocalForwardStack] = useState([]) const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) + const [pathsInSync, setPathsInSync] = useState(false) const [contextMenu, setContextMenu] = useState(null) const [showAddEnv, setShowAddEnv] = useState(false) const [conflictCount, setConflictCount] = useState(0) @@ -182,23 +185,35 @@ export default function DualPaneBrowser(): React.JSX.Element { useEffect(() => { if (compareMode === 'off') { setDiffMap(new Map()) + setPathsInSync(false) return } if (compareMode === 'on') { setDiffMap(compareEntries(localEntries, remoteEntries)) + setPathsInSync(true) return } - // auto: only compare when DW-relative path tails match (e.g. local .../Files/Templates - // and remote /Files/Templates both resolve to /files/templates) + // auto: only compare when DW-relative path tails match const localTail = getDwRelativeTail(localPath) const remoteTail = getDwRelativeTail(toDisplayRemotePath(remotePath)) if (localTail && remoteTail && localTail === remoteTail) { setDiffMap(compareEntries(localEntries, remoteEntries)) + setPathsInSync(true) } else { setDiffMap(new Map()) + setPathsInSync(false) } }, [compareMode, localEntries, remoteEntries, localPath, remotePath, setDiffMap]) + const syncCandidateKeys = useMemo(() => { + if (!pathsInSync || !syncNav) return null + const keys = new Set() + for (const [key, status] of diffMap) { + if (status === 'identical' || status === 'different') keys.add(key) + } + return keys + }, [diffMap, pathsInSync, syncNav]) + // User-initiated navigation: push the current path onto back, clear forward. async function navigateLocalTo(path: string): Promise { if (path === localPath) return @@ -268,11 +283,20 @@ export default function DualPaneBrowser(): React.JSX.Element { async function navigateLocal(entry: FileEntry): Promise { if (entry.type !== 'directory') return await navigateLocalTo(entry.path) + if (activeEnv && syncCandidateKeys?.has(diffKey(entry))) { + const remoteTarget = remotePath === '/' ? `/${entry.name}` : `${remotePath}/${entry.name}` + await navigateRemoteTo(remoteTarget) + } } async function navigateRemote(entry: FileEntry): Promise { if (!activeEnv || entry.type !== 'directory') return await navigateRemoteTo(entry.path) + if (syncCandidateKeys?.has(diffKey(entry))) { + const sep = localPath.includes('\\') ? '\\' : '/' + const localTarget = localPath ? `${localPath}${sep}${entry.name}` : entry.name + await navigateLocalTo(localTarget) + } } async function handleUpload(localPaths: string[], targetRemotePath: string): Promise { @@ -396,6 +420,9 @@ export default function DualPaneBrowser(): React.JSX.Element { diffMap={diffMap} highlightedStatuses={highlightedStatuses} onToggleStatus={toggleHighlightedStatus} + pathsInSync={pathsInSync} + syncNav={syncNav} + onSyncNavChange={setSyncNav} /> )}
@@ -439,6 +466,7 @@ export default function DualPaneBrowser(): React.JSX.Element { onSelect={(paths) => setSelected('local', paths)} pane="local" selected={localSelected} + syncCandidates={syncCandidateKeys ?? undefined} /> {/* Conflict banner */} @@ -633,6 +661,7 @@ export default function DualPaneBrowser(): React.JSX.Element { onSelect={(paths) => setSelected('remote', paths)} pane="remote" selected={remoteSelected} + syncCandidates={syncCandidateKeys ?? undefined} />
highlightedStatuses?: DiffStatus[] + syncCandidates?: Set } const DIFF_BORDER: Record = { @@ -105,7 +106,8 @@ export default function FileList({ dropTarget, pane, diffMap, - highlightedStatuses + highlightedStatuses, + syncCandidates }: FileListProps): React.JSX.Element { const isRemote = pane === 'remote' const [dragOverPath, setDragOverPath] = useState(null) @@ -211,8 +213,11 @@ export default function FileList({ const status = diffMap?.get(diffKey(entry)) const highlight = status && (highlightedStatuses?.includes(status) ?? false) ? status : undefined - const diffBorder = highlight ? DIFF_BORDER[highlight] : 'transparent' - const diffBg = highlight ? DIFF_BG[highlight] : undefined + const isSync = entry.type === 'directory' && !!syncCandidates?.has(diffKey(entry)) + const diffBorder = isSync ? '#2dd4bf' : highlight ? DIFF_BORDER[highlight] : 'transparent' + const diffBg = isSync + ? 'color-mix(in srgb, #2dd4bf 8%, transparent)' + : highlight ? DIFF_BG[highlight] : undefined return (
- {entry.type === 'directory' ? '' : formatSize(entry.size)} + {isSync ? ( + + ) : entry.type === 'directory' ? '' : formatSize(entry.size)}
) diff --git a/dw-desktop/src/renderer/src/stores/fileStore.ts b/dw-desktop/src/renderer/src/stores/fileStore.ts index 8cac1b7..aedbc40 100644 --- a/dw-desktop/src/renderer/src/stores/fileStore.ts +++ b/dw-desktop/src/renderer/src/stores/fileStore.ts @@ -3,6 +3,7 @@ import type { CompareMode, DiffStatus, FileEntry } from '../../../shared/types' const COMPARE_MODE_KEY = 'dw.compareMode' const HIGHLIGHTED_STATUSES_KEY = 'dw.highlightedStatuses' +const SYNC_NAV_KEY = 'dw.syncNav' const DEFAULT_HIGHLIGHTED: DiffStatus[] = ['different', 'remote-only'] function loadCompareMode(): CompareMode { @@ -38,8 +39,10 @@ interface FileState { loadRemote: (envName: string, path: string) => Promise loadLocal: (path: string, envName?: string | null) => Promise setSelected: (pane: 'local' | 'remote', paths: string[]) => void + syncNav: boolean setCompareMode: (mode: CompareMode) => void setDiffMap: (map: Map) => void + setSyncNav: (v: boolean) => void toggleHighlightedStatus: (status: DiffStatus) => void } @@ -61,6 +64,7 @@ export const useFileStore = create((set, get) => ({ compareMode: loadCompareMode(), diffMap: new Map(), highlightedStatuses: loadHighlighted(), + syncNav: localStorage.getItem(SYNC_NAV_KEY) === 'true', loadRemote: async (envName, path) => { const result = await window.dw.files.list(envName, path) @@ -96,6 +100,11 @@ export const useFileStore = create((set, get) => ({ set({ diffMap: map }) }, + setSyncNav: (v) => { + localStorage.setItem(SYNC_NAV_KEY, String(v)) + set({ syncNav: v }) + }, + toggleHighlightedStatus: (status) => { const current = get().highlightedStatuses const next = current.includes(status) From 6b307a6e7ffedf2cb82148fb55471a7c080ac897 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:16:27 +0300 Subject: [PATCH 08/55] feat: sync back/forward history navigation across both panes When sync nav is active, pressing the mouse back or forward button in either pane also triggers the same navigation in the other pane, keeping both panes in lockstep through their history stacks. Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/src/components/DualPaneBrowser.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index b023cb6..9b5cc58 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -429,8 +429,8 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Local pane */}
{ - if (e.button === 3 && localBackStack.length > 0) { e.preventDefault(); void goBackLocal() } - if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault(); void goForwardLocal() } + if (e.button === 3 && localBackStack.length > 0) { e.preventDefault(); void goBackLocal(); if (syncNav && pathsInSync && remoteBackStack.length > 0) void goBackRemote() } + if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault(); void goForwardLocal(); if (syncNav && pathsInSync && remoteForwardStack.length > 0) void goForwardRemote() } }} style={{ display: 'flex', @@ -575,8 +575,8 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Remote pane */}
{ - if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { e.preventDefault(); void goBackRemote() } - if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { e.preventDefault(); void goForwardRemote() } + if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { e.preventDefault(); void goBackRemote(); if (syncNav && pathsInSync && localBackStack.length > 0) void goBackLocal() } + if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { e.preventDefault(); void goForwardRemote(); if (syncNav && pathsInSync && localForwardStack.length > 0) void goForwardLocal() } }} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--pane-bg)' }} > From ac866531d0ff84818e3f5de8645c1e3dc8c7c9ca Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:18:02 +0300 Subject: [PATCH 09/55] feat: sync Up, Refresh, and breadcrumb navigation across both panes When sync nav is active, the Up button, Refresh button, and breadcrumb clicks in either pane mirror the same navigation in the other pane. Breadcrumb jumps count path segments to determine how many levels to go up on the opposite pane, keeping both in lockstep regardless of which pane initiated the navigation. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DualPaneBrowser.tsx | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 9b5cc58..bbd89a5 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -41,6 +41,10 @@ function parentPath(path: string): string { return parent } +function pathSegmentCount(p: string): number { + return p.replace(/\\/g, '/').replace(/^\//, '').replace(/\/$/, '').split('/').filter(Boolean).length +} + function localParentPath(path: string): string { if (!path) return '' // Windows drive root (C:\, C:/, or C:) → step up to the drives view. @@ -445,9 +449,26 @@ export default function DualPaneBrowser(): React.JSX.Element { void navigateLocalTo(localParentPath(localPath))} - onRefresh={() => void loadLocal(localPath)} - onNavigateTo={(p) => void navigateLocalTo(p)} + onNavigateUp={() => { + void navigateLocalTo(localParentPath(localPath)) + if (syncNav && pathsInSync) void navigateRemoteTo(parentPath(remotePath)) + }} + onRefresh={() => { + void loadLocal(localPath) + if (syncNav && pathsInSync && activeEnv) { + setRemoteLoading(true) + void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) + } + }} + onNavigateTo={(p) => { + const steps = pathSegmentCount(localPath) - pathSegmentCount(p) + void navigateLocalTo(p) + if (syncNav && pathsInSync && steps > 0) { + let remoteTarget = remotePath + for (let i = 0; i < steps; i++) remoteTarget = parentPath(remoteTarget) + void navigateRemoteTo(remoteTarget) + } + }} actions={ localSelected.length > 1 ? ( {localSelected.length} selected @@ -633,14 +654,24 @@ export default function DualPaneBrowser(): React.JSX.Element { path={toDisplayRemotePath(remotePath)} label={envLabel(activeEnv)} sublabel={activeEnv.host} - onNavigateUp={() => void navigateRemoteTo(parentPath(remotePath))} + onNavigateUp={() => { + void navigateRemoteTo(parentPath(remotePath)) + if (syncNav && pathsInSync) void navigateLocalTo(localParentPath(localPath)) + }} onRefresh={() => { setRemoteLoading(true) void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) + if (syncNav && pathsInSync) void loadLocal(localPath) }} onNavigateTo={(displayPath) => { const virtual = displayPath.replace(/^\/Files/, '') || '/' + const steps = pathSegmentCount(remotePath) - pathSegmentCount(virtual) void navigateRemoteTo(virtual) + if (syncNav && pathsInSync && steps > 0) { + let localTarget = localPath + for (let i = 0; i < steps; i++) localTarget = localParentPath(localTarget) + void navigateLocalTo(localTarget) + } }} actions={ remoteSelected.length > 1 ? ( From 17cabfb5cb0710eb37f867f2777d33840ec001d9 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:20:12 +0300 Subject: [PATCH 10/55] fix: remove teal sync highlight to avoid clashing with compare colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync indicator is now just the ⇄ icon in muted text — no row highlight or border. The Sync toolbar button uses the standard accent color when active. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/CompareToolbar.tsx | 6 +++--- dw-desktop/src/renderer/src/components/FileList.tsx | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 37110e9..4265447 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -118,10 +118,10 @@ export default function CompareToolbar({ fontSize: 11, padding: '2px 10px', borderRadius: 'var(--r-sm)', - border: `1px solid ${syncNav ? '#2dd4bf' : 'var(--border)'}`, + border: `1px solid ${syncNav ? 'var(--accent)' : 'var(--border)'}`, cursor: 'pointer', - background: syncNav ? 'color-mix(in srgb, #2dd4bf 15%, transparent)' : 'var(--surface-raised)', - color: syncNav ? '#2dd4bf' : 'var(--text-subtle)', + background: syncNav ? 'var(--accent)' : 'var(--surface-raised)', + color: syncNav ? '#fff' : 'var(--text-subtle)', transition: 'background 80ms ease, color 80ms ease, border-color 80ms ease', userSelect: 'none' }} diff --git a/dw-desktop/src/renderer/src/components/FileList.tsx b/dw-desktop/src/renderer/src/components/FileList.tsx index f460f50..6eeacf1 100644 --- a/dw-desktop/src/renderer/src/components/FileList.tsx +++ b/dw-desktop/src/renderer/src/components/FileList.tsx @@ -214,10 +214,8 @@ export default function FileList({ const highlight = status && (highlightedStatuses?.includes(status) ?? false) ? status : undefined const isSync = entry.type === 'directory' && !!syncCandidates?.has(diffKey(entry)) - const diffBorder = isSync ? '#2dd4bf' : highlight ? DIFF_BORDER[highlight] : 'transparent' - const diffBg = isSync - ? 'color-mix(in srgb, #2dd4bf 8%, transparent)' - : highlight ? DIFF_BG[highlight] : undefined + const diffBorder = highlight ? DIFF_BORDER[highlight] : 'transparent' + const diffBg = highlight ? DIFF_BG[highlight] : undefined return (
{isSync ? ( - + ) : entry.type === 'directory' ? '' : formatSize(entry.size)}
From 332a61661cb28de36065e23ddf2bf89c3b8c1331 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:23:09 +0300 Subject: [PATCH 11/55] refactor: replace Compare segmented control with single toggle button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare now has two states — off and auto — presented as a single toggle button styled consistently with the Sync button. Clicking toggles between off and auto; the 'on' mode is no longer exposed in the UI. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 61 ++++++------------- 1 file changed, 17 insertions(+), 44 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 4265447..034e194 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -12,12 +12,6 @@ interface CompareToolbarProps { onSyncNavChange: (v: boolean) => void } -const MODES: { value: CompareMode; label: string }[] = [ - { value: 'off', label: 'Off' }, - { value: 'auto', label: 'Auto' }, - { value: 'on', label: 'On' } -] - const STATUSES: DiffStatus[] = ['different', 'remote-only', 'local-only', 'identical'] const PILL_COLOR: Record = { @@ -46,6 +40,7 @@ export default function CompareToolbar({ }: CompareToolbarProps): React.JSX.Element { const counts = countByStatus(diffMap) const hasDiff = diffMap.size > 0 + const compareOn = mode !== 'off' return (
- - Compare - -
onModeChange(compareOn ? 'off' : 'auto')} style={{ display: 'flex', + alignItems: 'center', + gap: 5, + fontSize: 11, + padding: '2px 10px', borderRadius: 'var(--r-sm)', - overflow: 'hidden', - border: '1px solid var(--border)' + border: `1px solid ${compareOn ? 'var(--accent)' : 'var(--border)'}`, + cursor: 'pointer', + background: compareOn ? 'var(--accent)' : 'var(--surface-raised)', + color: compareOn ? '#fff' : 'var(--text-subtle)', + transition: 'background 80ms ease, color 80ms ease, border-color 80ms ease', + userSelect: 'none' }} > - {MODES.map((m, i) => { - const active = mode === m.value - return ( - - ) - })} -
+ Compare + {pathsInSync && ( <> From c76adae4ba8bb78924acb2d54324a064fd1630e7 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:25:36 +0300 Subject: [PATCH 12/55] fix: always show Sync button; auto-mode behavior when structures match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync button is now always visible alongside Compare. When enabled it acts like auto — navigation syncs only when folder structures match, otherwise it's a no-op. Mirrors the Compare convention exactly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 50 ++++++++----------- .../src/components/DualPaneBrowser.tsx | 1 - 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 034e194..21cb8cd 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -7,7 +7,6 @@ interface CompareToolbarProps { diffMap: Map highlightedStatuses: DiffStatus[] onToggleStatus: (status: DiffStatus) => void - pathsInSync: boolean syncNav: boolean onSyncNavChange: (v: boolean) => void } @@ -34,7 +33,6 @@ export default function CompareToolbar({ diffMap, highlightedStatuses, onToggleStatus, - pathsInSync, syncNav, onSyncNavChange }: CompareToolbarProps): React.JSX.Element { @@ -77,32 +75,28 @@ export default function CompareToolbar({ Compare - {pathsInSync && ( - <> - - - - )} + + {hasDiff && (
diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index bbd89a5..163c89a 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -424,7 +424,6 @@ export default function DualPaneBrowser(): React.JSX.Element { diffMap={diffMap} highlightedStatuses={highlightedStatuses} onToggleStatus={toggleHighlightedStatus} - pathsInSync={pathsInSync} syncNav={syncNav} onSyncNavChange={setSyncNav} /> From fc70c925df1decd4d98b8783191001b6a20437c6 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:28:01 +0300 Subject: [PATCH 13/55] fix: strip trailing slash in getDwRelativeTail before regex match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toDisplayRemotePath('/') returns '/Files/' with a trailing slash, causing getDwRelativeTail to return '/files/' instead of '/files' — breaking the tail comparison against a local path ending in \Files. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/utils/compareEntries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dw-desktop/src/renderer/src/utils/compareEntries.ts b/dw-desktop/src/renderer/src/utils/compareEntries.ts index c08adda..28dc51d 100644 --- a/dw-desktop/src/renderer/src/utils/compareEntries.ts +++ b/dw-desktop/src/renderer/src/utils/compareEntries.ts @@ -74,7 +74,7 @@ export function countByStatus(diff: Map): Record Date: Wed, 29 Apr 2026 10:30:20 +0300 Subject: [PATCH 14/55] refactor: move Sync button to end of toolbar after diff pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order is now: Compare | diff pills | separator | Sync — keeping compare controls grouped together and Sync separated at the far end. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 21cb8cd..293561e 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -75,29 +75,6 @@ export default function CompareToolbar({ Compare - - - {hasDiff && (
{STATUSES.map((status) => { @@ -144,6 +121,29 @@ export default function CompareToolbar({ })}
)} + + +
) } From 20dc7cbf51a5d879d22f10e9b25fd660db51f53d Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:31:18 +0300 Subject: [PATCH 15/55] fix: clarify Sync button tooltip wording Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/CompareToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 293561e..49bd196 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -125,7 +125,7 @@ export default function CompareToolbar({ {hasDiff && ( From efd59894dcadaa677ae37e3fe75003e498f1628d Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:35:11 +0300 Subject: [PATCH 17/55] feat: highlight matching folder in opposite pane on selection when sync is active When sync navigation is enabled and a folder is clicked in one pane, the corresponding folder in the other pane gets a subtle accent outline to show where the sync would navigate to. Clears when selection changes or sync is off. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DualPaneBrowser.tsx | 26 +++++++++++++++++-- .../src/renderer/src/components/FileList.tsx | 7 +++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 163c89a..c030d68 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -98,6 +98,8 @@ export default function DualPaneBrowser(): React.JSX.Element { const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) const [pathsInSync, setPathsInSync] = useState(false) + const [localMirrorPaths, setLocalMirrorPaths] = useState([]) + const [remoteMirrorPaths, setRemoteMirrorPaths] = useState([]) const [contextMenu, setContextMenu] = useState(null) const [showAddEnv, setShowAddEnv] = useState(false) const [conflictCount, setConflictCount] = useState(0) @@ -483,10 +485,20 @@ export default function DualPaneBrowser(): React.JSX.Element { onContextMenu={(entry, x, y) => setContextMenu({ entry, x, y, pane: 'local' })} onDoubleClick={(entry) => void navigateLocal(entry)} onDropOnPane={(paths) => void handleDownload(paths)} - onSelect={(paths) => setSelected('local', paths)} + onSelect={(paths) => { + setSelected('local', paths) + if (syncNav && pathsInSync) { + const names = new Set(paths.map((p) => p.split(/[\\/]/).pop() ?? '')) + setRemoteMirrorPaths(remoteEntries.filter((e) => e.type === 'directory' && names.has(e.name)).map((e) => e.path)) + } else { + setRemoteMirrorPaths([]) + } + setLocalMirrorPaths([]) + }} pane="local" selected={localSelected} syncCandidates={syncCandidateKeys ?? undefined} + mirrorPaths={remoteMirrorPaths.length > 0 ? remoteMirrorPaths : undefined} /> {/* Conflict banner */} @@ -688,10 +700,20 @@ export default function DualPaneBrowser(): React.JSX.Element { onDoubleClick={(entry) => void navigateRemote(entry)} onDropIntoDir={(paths, targetDir) => void handleUpload(paths, targetDir.path)} onDropOnPane={(paths) => void handleUpload(paths, remotePath)} - onSelect={(paths) => setSelected('remote', paths)} + onSelect={(paths) => { + setSelected('remote', paths) + if (syncNav && pathsInSync) { + const names = new Set(paths.map((p) => p.split('/').pop() ?? '')) + setLocalMirrorPaths(localEntries.filter((e) => e.type === 'directory' && names.has(e.name)).map((e) => e.path)) + } else { + setLocalMirrorPaths([]) + } + setRemoteMirrorPaths([]) + }} pane="remote" selected={remoteSelected} syncCandidates={syncCandidateKeys ?? undefined} + mirrorPaths={localMirrorPaths.length > 0 ? localMirrorPaths : undefined} />
highlightedStatuses?: DiffStatus[] syncCandidates?: Set + mirrorPaths?: string[] } const DIFF_BORDER: Record = { @@ -107,7 +108,8 @@ export default function FileList({ pane, diffMap, highlightedStatuses, - syncCandidates + syncCandidates, + mirrorPaths }: FileListProps): React.JSX.Element { const isRemote = pane === 'remote' const [dragOverPath, setDragOverPath] = useState(null) @@ -214,6 +216,7 @@ export default function FileList({ const highlight = status && (highlightedStatuses?.includes(status) ?? false) ? status : undefined const isSync = entry.type === 'directory' && !!syncCandidates?.has(diffKey(entry)) + const isMirror = !!mirrorPaths?.includes(entry.path) const diffBorder = highlight ? DIFF_BORDER[highlight] : 'transparent' const diffBg = highlight ? DIFF_BG[highlight] : undefined return ( @@ -268,7 +271,7 @@ export default function FileList({ transition: 'background 80ms ease-out', background: isSelected ? 'var(--selection)' : (diffBg ?? 'transparent'), color: 'var(--text)', - outline: isDragOver ? '1px dashed var(--accent-cool)' : 'none', + outline: isDragOver ? '1px dashed var(--accent-cool)' : isMirror ? '1px solid var(--accent)' : 'none', outlineOffset: -1, opacity: isDragging ? 0.4 : 1 }} From 37840b07bf9d308e7d940eb77629bfe7d7ddb3de Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:37:02 +0300 Subject: [PATCH 18/55] fix: correct swapped mirror paths between local and remote FileList remoteMirrorPaths (set on local click) was being passed to local FileList instead of remote, and vice versa. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index c030d68..8e8d012 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -498,7 +498,7 @@ export default function DualPaneBrowser(): React.JSX.Element { pane="local" selected={localSelected} syncCandidates={syncCandidateKeys ?? undefined} - mirrorPaths={remoteMirrorPaths.length > 0 ? remoteMirrorPaths : undefined} + mirrorPaths={localMirrorPaths.length > 0 ? localMirrorPaths : undefined} /> {/* Conflict banner */} @@ -713,7 +713,7 @@ export default function DualPaneBrowser(): React.JSX.Element { pane="remote" selected={remoteSelected} syncCandidates={syncCandidateKeys ?? undefined} - mirrorPaths={localMirrorPaths.length > 0 ? localMirrorPaths : undefined} + mirrorPaths={remoteMirrorPaths.length > 0 ? remoteMirrorPaths : undefined} />
Date: Wed, 29 Apr 2026 10:39:16 +0300 Subject: [PATCH 19/55] refactor: reuse selection style for sync mirror highlight Instead of a custom outline, mirror paths are merged into the selected array so they render with the existing selection background. No new visual styles added to FileList. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx | 6 ++---- dw-desktop/src/renderer/src/components/FileList.tsx | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 8e8d012..3d246c2 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -496,9 +496,8 @@ export default function DualPaneBrowser(): React.JSX.Element { setLocalMirrorPaths([]) }} pane="local" - selected={localSelected} + selected={localMirrorPaths.length > 0 ? [...localSelected, ...localMirrorPaths] : localSelected} syncCandidates={syncCandidateKeys ?? undefined} - mirrorPaths={localMirrorPaths.length > 0 ? localMirrorPaths : undefined} /> {/* Conflict banner */} @@ -711,9 +710,8 @@ export default function DualPaneBrowser(): React.JSX.Element { setRemoteMirrorPaths([]) }} pane="remote" - selected={remoteSelected} + selected={remoteMirrorPaths.length > 0 ? [...remoteSelected, ...remoteMirrorPaths] : remoteSelected} syncCandidates={syncCandidateKeys ?? undefined} - mirrorPaths={remoteMirrorPaths.length > 0 ? remoteMirrorPaths : undefined} />
highlightedStatuses?: DiffStatus[] syncCandidates?: Set - mirrorPaths?: string[] } const DIFF_BORDER: Record = { @@ -108,8 +107,7 @@ export default function FileList({ pane, diffMap, highlightedStatuses, - syncCandidates, - mirrorPaths + syncCandidates }: FileListProps): React.JSX.Element { const isRemote = pane === 'remote' const [dragOverPath, setDragOverPath] = useState(null) @@ -216,7 +214,6 @@ export default function FileList({ const highlight = status && (highlightedStatuses?.includes(status) ?? false) ? status : undefined const isSync = entry.type === 'directory' && !!syncCandidates?.has(diffKey(entry)) - const isMirror = !!mirrorPaths?.includes(entry.path) const diffBorder = highlight ? DIFF_BORDER[highlight] : 'transparent' const diffBg = highlight ? DIFF_BG[highlight] : undefined return ( @@ -271,7 +268,7 @@ export default function FileList({ transition: 'background 80ms ease-out', background: isSelected ? 'var(--selection)' : (diffBg ?? 'transparent'), color: 'var(--text)', - outline: isDragOver ? '1px dashed var(--accent-cool)' : isMirror ? '1px solid var(--accent)' : 'none', + outline: isDragOver ? '1px dashed var(--accent-cool)' : 'none', outlineOffset: -1, opacity: isDragging ? 0.4 : 1 }} From 6f855fb12ecdf33c86aec4548636bc5faba24a97 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:40:07 +0300 Subject: [PATCH 20/55] refactor: shorten pill labels from 'remote only'/'local only' to 'remote'/'local' Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/CompareToolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 5a8c8f6..b5f4ef1 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -22,8 +22,8 @@ const PILL_COLOR: Record = { const LABEL: Record = { different: 'different', - 'remote-only': 'remote only', - 'local-only': 'local only', + 'remote-only': 'remote', + 'local-only': 'local', identical: 'identical' } From a37e3c206713fc5491a10ad1a60a4e4584bf0e68 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:41:09 +0300 Subject: [PATCH 21/55] refactor: rename 'identical' pill label to 'equal' Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/CompareToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index b5f4ef1..c80b94c 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -24,7 +24,7 @@ const LABEL: Record = { different: 'different', 'remote-only': 'remote', 'local-only': 'local', - identical: 'identical' + identical: 'equal' } export default function CompareToolbar({ From bc1abe59e6a7fb928b749ebcbfdb91193238c95e Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:42:50 +0300 Subject: [PATCH 22/55] refactor: align Compare and Sync button style with ThemeSwitcher Uses border-strong, height 22, font-size 10, uppercase + letter-spacing to match the Light/Auto/Dark theme selector styling exactly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index c80b94c..a6663ee 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -58,17 +58,18 @@ export default function CompareToolbar({ title={compareOn ? 'Disable compare' : 'Enable compare — detects differences by comparing file sizes'} onClick={() => onModeChange(compareOn ? 'off' : 'auto')} style={{ - display: 'flex', - alignItems: 'center', - gap: 5, - fontSize: 11, - padding: '2px 10px', + height: 22, + padding: '0 8px', + fontSize: 10, + fontFamily: 'var(--font-ui)', + letterSpacing: '0.04em', + textTransform: 'uppercase', borderRadius: 'var(--r-sm)', - border: `1px solid ${compareOn ? 'var(--accent)' : 'var(--border)'}`, + border: '1px solid var(--border-strong)', cursor: 'pointer', background: compareOn ? 'var(--accent)' : 'var(--surface-raised)', color: compareOn ? '#fff' : 'var(--text-subtle)', - transition: 'background 80ms ease, color 80ms ease, border-color 80ms ease', + transition: 'background 80ms ease-out, color 80ms ease-out', userSelect: 'none' }} > @@ -128,17 +129,18 @@ export default function CompareToolbar({ title={syncNav ? 'Disable synchronized navigation' : 'Enable synchronized navigation — navigates both panes together when folder structures match'} onClick={() => onSyncNavChange(!syncNav)} style={{ - display: 'flex', - alignItems: 'center', - gap: 5, - fontSize: 11, - padding: '2px 10px', + height: 22, + padding: '0 8px', + fontSize: 10, + fontFamily: 'var(--font-ui)', + letterSpacing: '0.04em', + textTransform: 'uppercase', borderRadius: 'var(--r-sm)', - border: `1px solid ${syncNav ? 'var(--accent)' : 'var(--border)'}`, + border: '1px solid var(--border-strong)', cursor: 'pointer', background: syncNav ? 'var(--accent)' : 'var(--surface-raised)', color: syncNav ? '#fff' : 'var(--text-subtle)', - transition: 'background 80ms ease, color 80ms ease, border-color 80ms ease', + transition: 'background 80ms ease-out, color 80ms ease-out', userSelect: 'none' }} > From 8c4006b83207166f14386e6848bf47b3cf39e8a3 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:43:50 +0300 Subject: [PATCH 23/55] fix: default compareMode to 'off' when no localStorage value exists Previously defaulted to 'auto', enabling compare on first launch without user intent. Both Compare and Sync now start disabled by default. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/stores/fileStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dw-desktop/src/renderer/src/stores/fileStore.ts b/dw-desktop/src/renderer/src/stores/fileStore.ts index aedbc40..74d4886 100644 --- a/dw-desktop/src/renderer/src/stores/fileStore.ts +++ b/dw-desktop/src/renderer/src/stores/fileStore.ts @@ -8,7 +8,7 @@ const DEFAULT_HIGHLIGHTED: DiffStatus[] = ['different', 'remote-only'] function loadCompareMode(): CompareMode { const v = localStorage.getItem(COMPARE_MODE_KEY) - return v === 'off' || v === 'on' ? v : 'auto' + return v === 'auto' || v === 'on' ? v : 'off' } function loadHighlighted(): DiffStatus[] { From f3ccdaaa3394e95be878a70709ace292bb16128d Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 10:44:52 +0300 Subject: [PATCH 24/55] fix: update Compare button tooltip to 'Enable/Disable file size comparison' Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/CompareToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index a6663ee..9e6f2be 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -55,7 +55,7 @@ export default function CompareToolbar({ >
) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 4f8f9b5..0601007 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -79,13 +79,13 @@ export default function DualPaneBrowser(): React.JSX.Element { compareMode, diffMap, highlightedStatuses, - syncNav, + mirrorNav, loadRemote, loadLocal, setSelected, setCompareMode, setDiffMap, - setSyncNav, + setMirrorNav, toggleHighlightedStatus } = useFileStore() const { addJob } = useTransferStore() @@ -97,7 +97,7 @@ export default function DualPaneBrowser(): React.JSX.Element { const [localForwardStack, setLocalForwardStack] = useState([]) const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) - const [pathsInSync, setPathsInSync] = useState(false) + const [pathsMatch, setPathsInSync] = useState(false) const [localMirrorPaths, setLocalMirrorPaths] = useState([]) const [remoteMirrorPaths, setRemoteMirrorPaths] = useState([]) const [contextMenu, setContextMenu] = useState(null) @@ -205,8 +205,8 @@ export default function DualPaneBrowser(): React.JSX.Element { } }, [compareMode, localEntries, remoteEntries, localPath, remotePath, setDiffMap]) - const syncCandidateKeys = useMemo(() => { - if (!pathsInSync || !syncNav) return null + const mirrorCandidateKeys = useMemo(() => { + if (!pathsMatch || !mirrorNav) return null if (diffMap.size > 0) { const keys = new Set() for (const [key, status] of diffMap) { @@ -221,7 +221,7 @@ export default function DualPaneBrowser(): React.JSX.Element { if (e.type === 'directory' && remoteKeys.has(diffKey(e))) keys.add(diffKey(e)) } return keys - }, [diffMap, pathsInSync, syncNav, localEntries, remoteEntries]) + }, [diffMap, pathsMatch, mirrorNav, localEntries, remoteEntries]) // User-initiated navigation: push the current path onto back, clear forward. async function navigateLocalTo(path: string): Promise { @@ -292,7 +292,7 @@ export default function DualPaneBrowser(): React.JSX.Element { async function navigateLocal(entry: FileEntry): Promise { if (entry.type !== 'directory') return await navigateLocalTo(entry.path) - if (activeEnv && syncCandidateKeys?.has(diffKey(entry))) { + if (activeEnv && mirrorCandidateKeys?.has(diffKey(entry))) { const remoteTarget = remotePath === '/' ? `/${entry.name}` : `${remotePath}/${entry.name}` await navigateRemoteTo(remoteTarget) } @@ -301,7 +301,7 @@ export default function DualPaneBrowser(): React.JSX.Element { async function navigateRemote(entry: FileEntry): Promise { if (!activeEnv || entry.type !== 'directory') return await navigateRemoteTo(entry.path) - if (syncCandidateKeys?.has(diffKey(entry))) { + if (mirrorCandidateKeys?.has(diffKey(entry))) { const sep = localPath.includes('\\') ? '\\' : '/' const localTarget = localPath ? `${localPath}${sep}${entry.name}` : entry.name await navigateLocalTo(localTarget) @@ -429,8 +429,8 @@ export default function DualPaneBrowser(): React.JSX.Element { diffMap={diffMap} highlightedStatuses={highlightedStatuses} onToggleStatus={toggleHighlightedStatus} - syncNav={syncNav} - onSyncNavChange={setSyncNav} + mirrorNav={mirrorNav} + onMirrorNavChange={setMirrorNav} /> )}
@@ -455,11 +455,11 @@ export default function DualPaneBrowser(): React.JSX.Element { label="Local" onNavigateUp={() => { void navigateLocalTo(localParentPath(localPath)) - if (syncNav && pathsInSync) void navigateRemoteTo(parentPath(remotePath)) + if (mirrorNav && pathsMatch) void navigateRemoteTo(parentPath(remotePath)) }} onRefresh={() => { void loadLocal(localPath) - if (syncNav && pathsInSync && activeEnv) { + if (mirrorNav && pathsMatch && activeEnv) { setRemoteLoading(true) void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) } @@ -467,7 +467,7 @@ export default function DualPaneBrowser(): React.JSX.Element { onNavigateTo={(p) => { const steps = pathSegmentCount(localPath) - pathSegmentCount(p) void navigateLocalTo(p) - if (syncNav && pathsInSync && steps > 0) { + if (mirrorNav && pathsMatch && steps > 0) { let remoteTarget = remotePath for (let i = 0; i < steps; i++) remoteTarget = parentPath(remoteTarget) void navigateRemoteTo(remoteTarget) @@ -490,7 +490,7 @@ export default function DualPaneBrowser(): React.JSX.Element { onDropOnPane={(paths) => void handleDownload(paths)} onSelect={(paths) => { setSelected('local', paths) - if (syncNav && pathsInSync) { + if (mirrorNav && pathsMatch) { const names = new Set(paths.map((p) => p.split(/[\\/]/).pop() ?? '')) setRemoteMirrorPaths(remoteEntries.filter((e) => e.type === 'directory' && names.has(e.name)).map((e) => e.path)) } else { @@ -500,7 +500,7 @@ export default function DualPaneBrowser(): React.JSX.Element { }} pane="local" selected={localMirrorPaths.length > 0 ? [...localSelected, ...localMirrorPaths] : localSelected} - syncCandidates={syncCandidateKeys ?? undefined} + syncCandidates={mirrorCandidateKeys ?? undefined} /> {/* Conflict banner */} @@ -669,18 +669,18 @@ export default function DualPaneBrowser(): React.JSX.Element { sublabel={activeEnv.host} onNavigateUp={() => { void navigateRemoteTo(parentPath(remotePath)) - if (syncNav && pathsInSync) void navigateLocalTo(localParentPath(localPath)) + if (mirrorNav && pathsMatch) void navigateLocalTo(localParentPath(localPath)) }} onRefresh={() => { setRemoteLoading(true) void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) - if (syncNav && pathsInSync) void loadLocal(localPath) + if (mirrorNav && pathsMatch) void loadLocal(localPath) }} onNavigateTo={(displayPath) => { const virtual = displayPath.replace(/^\/Files/, '') || '/' const steps = pathSegmentCount(remotePath) - pathSegmentCount(virtual) void navigateRemoteTo(virtual) - if (syncNav && pathsInSync && steps > 0) { + if (mirrorNav && pathsMatch && steps > 0) { let localTarget = localPath for (let i = 0; i < steps; i++) localTarget = localParentPath(localTarget) void navigateLocalTo(localTarget) @@ -704,7 +704,7 @@ export default function DualPaneBrowser(): React.JSX.Element { onDropOnPane={(paths) => void handleUpload(paths, remotePath)} onSelect={(paths) => { setSelected('remote', paths) - if (syncNav && pathsInSync) { + if (mirrorNav && pathsMatch) { const names = new Set(paths.map((p) => p.split('/').pop() ?? '')) setLocalMirrorPaths(localEntries.filter((e) => e.type === 'directory' && names.has(e.name)).map((e) => e.path)) } else { @@ -714,7 +714,7 @@ export default function DualPaneBrowser(): React.JSX.Element { }} pane="remote" selected={remoteMirrorPaths.length > 0 ? [...remoteSelected, ...remoteMirrorPaths] : remoteSelected} - syncCandidates={syncCandidateKeys ?? undefined} + syncCandidates={mirrorCandidateKeys ?? undefined} />
Promise loadLocal: (path: string, envName?: string | null) => Promise setSelected: (pane: 'local' | 'remote', paths: string[]) => void - syncNav: boolean + mirrorNav: boolean setCompareMode: (mode: CompareMode) => void setDiffMap: (map: Map) => void - setSyncNav: (v: boolean) => void + setMirrorNav: (v: boolean) => void toggleHighlightedStatus: (status: DiffStatus) => void } @@ -64,7 +64,7 @@ export const useFileStore = create((set, get) => ({ compareMode: loadCompareMode(), diffMap: new Map(), highlightedStatuses: loadHighlighted(), - syncNav: localStorage.getItem(SYNC_NAV_KEY) === 'true', + mirrorNav: localStorage.getItem(MIRROR_NAV_KEY) === 'true', loadRemote: async (envName, path) => { const result = await window.dw.files.list(envName, path) @@ -100,9 +100,9 @@ export const useFileStore = create((set, get) => ({ set({ diffMap: map }) }, - setSyncNav: (v) => { - localStorage.setItem(SYNC_NAV_KEY, String(v)) - set({ syncNav: v }) + setMirrorNav: (v) => { + localStorage.setItem(MIRROR_NAV_KEY, String(v)) + set({ mirrorNav: v }) }, toggleHighlightedStatus: (status) => { From 56c4d7c40b6a09c087a15dd041d4e696aca9535f Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 11:50:19 +0300 Subject: [PATCH 30/55] feat: mirror history stack for back/forward navigation Adds dedicated mirrorBackStack/mirrorForwardStack that records local+remote path pairs whenever mirror navigation occurs. Back/forward mouse buttons check the mirror stack first when mirrorNav is on, navigating both panes together; falls back to per-pane history when the mirror stack is empty or mirror is off. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DualPaneBrowser.tsx | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 0601007..d7add2f 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -98,6 +98,8 @@ export default function DualPaneBrowser(): React.JSX.Element { const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) const [pathsMatch, setPathsInSync] = useState(false) + const [mirrorBackStack, setMirrorBackStack] = useState<{ local: string; remote: string }[]>([]) + const [mirrorForwardStack, setMirrorForwardStack] = useState<{ local: string; remote: string }[]>([]) const [localMirrorPaths, setLocalMirrorPaths] = useState([]) const [remoteMirrorPaths, setRemoteMirrorPaths] = useState([]) const [contextMenu, setContextMenu] = useState(null) @@ -289,10 +291,37 @@ export default function DualPaneBrowser(): React.JSX.Element { }) } + function pushMirrorHistory(): void { + setMirrorBackStack((b) => [{ local: localPath, remote: remotePath }, ...b]) + setMirrorForwardStack([]) + } + + function doMirrorBack(): void { + const [pair, ...rest] = mirrorBackStack + setMirrorBackStack(rest) + setMirrorForwardStack((f) => [{ local: localPath, remote: remotePath }, ...f]) + setLocalLoading(true) + setRemoteLoading(true) + void loadLocal(pair.local).finally(() => setLocalLoading(false)) + if (activeEnv) void loadRemote(activeEnv.name, pair.remote).finally(() => setRemoteLoading(false)) + } + + function doMirrorForward(): void { + const [pair, ...rest] = mirrorForwardStack + setMirrorForwardStack(rest) + setMirrorBackStack((b) => [{ local: localPath, remote: remotePath }, ...b]) + setLocalLoading(true) + setRemoteLoading(true) + void loadLocal(pair.local).finally(() => setLocalLoading(false)) + if (activeEnv) void loadRemote(activeEnv.name, pair.remote).finally(() => setRemoteLoading(false)) + } + async function navigateLocal(entry: FileEntry): Promise { if (entry.type !== 'directory') return + const isMirror = !!(activeEnv && mirrorCandidateKeys?.has(diffKey(entry))) + if (isMirror) pushMirrorHistory() await navigateLocalTo(entry.path) - if (activeEnv && mirrorCandidateKeys?.has(diffKey(entry))) { + if (isMirror) { const remoteTarget = remotePath === '/' ? `/${entry.name}` : `${remotePath}/${entry.name}` await navigateRemoteTo(remoteTarget) } @@ -300,8 +329,10 @@ export default function DualPaneBrowser(): React.JSX.Element { async function navigateRemote(entry: FileEntry): Promise { if (!activeEnv || entry.type !== 'directory') return + const isMirror = mirrorCandidateKeys?.has(diffKey(entry)) ?? false + if (isMirror) pushMirrorHistory() await navigateRemoteTo(entry.path) - if (mirrorCandidateKeys?.has(diffKey(entry))) { + if (isMirror) { const sep = localPath.includes('\\') ? '\\' : '/' const localTarget = localPath ? `${localPath}${sep}${entry.name}` : entry.name await navigateLocalTo(localTarget) @@ -437,8 +468,14 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Local pane */}
{ - if (e.button === 3 && localBackStack.length > 0) { e.preventDefault(); void goBackLocal() } - if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault(); void goForwardLocal() } + if (e.button === 3) { + if (mirrorNav && mirrorBackStack.length > 0) { e.preventDefault(); doMirrorBack() } + else if (localBackStack.length > 0) { e.preventDefault(); void goBackLocal() } + } + if (e.button === 4) { + if (mirrorNav && mirrorForwardStack.length > 0) { e.preventDefault(); doMirrorForward() } + else if (localForwardStack.length > 0) { e.preventDefault(); void goForwardLocal() } + } }} style={{ display: 'flex', @@ -454,6 +491,7 @@ export default function DualPaneBrowser(): React.JSX.Element { path={localPath} label="Local" onNavigateUp={() => { + if (mirrorNav && pathsMatch) pushMirrorHistory() void navigateLocalTo(localParentPath(localPath)) if (mirrorNav && pathsMatch) void navigateRemoteTo(parentPath(remotePath)) }} @@ -466,6 +504,7 @@ export default function DualPaneBrowser(): React.JSX.Element { }} onNavigateTo={(p) => { const steps = pathSegmentCount(localPath) - pathSegmentCount(p) + if (mirrorNav && pathsMatch && steps > 0) pushMirrorHistory() void navigateLocalTo(p) if (mirrorNav && pathsMatch && steps > 0) { let remoteTarget = remotePath @@ -609,8 +648,14 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Remote pane */}
{ - if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { e.preventDefault(); void goBackRemote() } - if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { e.preventDefault(); void goForwardRemote() } + if (e.button === 3) { + if (mirrorNav && mirrorBackStack.length > 0) { e.preventDefault(); doMirrorBack() } + else if (activeEnv && remoteBackStack.length > 0) { e.preventDefault(); void goBackRemote() } + } + if (e.button === 4) { + if (mirrorNav && mirrorForwardStack.length > 0) { e.preventDefault(); doMirrorForward() } + else if (activeEnv && remoteForwardStack.length > 0) { e.preventDefault(); void goForwardRemote() } + } }} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--pane-bg)' }} > @@ -668,6 +713,7 @@ export default function DualPaneBrowser(): React.JSX.Element { label={envLabel(activeEnv)} sublabel={activeEnv.host} onNavigateUp={() => { + if (mirrorNav && pathsMatch) pushMirrorHistory() void navigateRemoteTo(parentPath(remotePath)) if (mirrorNav && pathsMatch) void navigateLocalTo(localParentPath(localPath)) }} @@ -679,6 +725,7 @@ export default function DualPaneBrowser(): React.JSX.Element { onNavigateTo={(displayPath) => { const virtual = displayPath.replace(/^\/Files/, '') || '/' const steps = pathSegmentCount(remotePath) - pathSegmentCount(virtual) + if (mirrorNav && pathsMatch && steps > 0) pushMirrorHistory() void navigateRemoteTo(virtual) if (mirrorNav && pathsMatch && steps > 0) { let localTarget = localPath From 1bd6986f0fac4531dd9dec723cdd548dd86b6900 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 11:58:26 +0300 Subject: [PATCH 31/55] =?UTF-8?q?refactor:=20simplify=20back/forward=20?= =?UTF-8?q?=E2=80=94=20use=20per-pane=20stacks,=20couple=20when=20mirrored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the dedicated mirror history stack. Back/forward now uses the existing per-pane stacks for each pane. When mirrorNav is on and paths are in sync, pressing back/forward on either pane navigates both panes together. When not in sync, only the pane receiving the event moves. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DualPaneBrowser.tsx | 61 +++++-------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index d7add2f..061de79 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -98,8 +98,6 @@ export default function DualPaneBrowser(): React.JSX.Element { const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) const [pathsMatch, setPathsInSync] = useState(false) - const [mirrorBackStack, setMirrorBackStack] = useState<{ local: string; remote: string }[]>([]) - const [mirrorForwardStack, setMirrorForwardStack] = useState<{ local: string; remote: string }[]>([]) const [localMirrorPaths, setLocalMirrorPaths] = useState([]) const [remoteMirrorPaths, setRemoteMirrorPaths] = useState([]) const [contextMenu, setContextMenu] = useState(null) @@ -291,35 +289,9 @@ export default function DualPaneBrowser(): React.JSX.Element { }) } - function pushMirrorHistory(): void { - setMirrorBackStack((b) => [{ local: localPath, remote: remotePath }, ...b]) - setMirrorForwardStack([]) - } - - function doMirrorBack(): void { - const [pair, ...rest] = mirrorBackStack - setMirrorBackStack(rest) - setMirrorForwardStack((f) => [{ local: localPath, remote: remotePath }, ...f]) - setLocalLoading(true) - setRemoteLoading(true) - void loadLocal(pair.local).finally(() => setLocalLoading(false)) - if (activeEnv) void loadRemote(activeEnv.name, pair.remote).finally(() => setRemoteLoading(false)) - } - - function doMirrorForward(): void { - const [pair, ...rest] = mirrorForwardStack - setMirrorForwardStack(rest) - setMirrorBackStack((b) => [{ local: localPath, remote: remotePath }, ...b]) - setLocalLoading(true) - setRemoteLoading(true) - void loadLocal(pair.local).finally(() => setLocalLoading(false)) - if (activeEnv) void loadRemote(activeEnv.name, pair.remote).finally(() => setRemoteLoading(false)) - } - async function navigateLocal(entry: FileEntry): Promise { if (entry.type !== 'directory') return const isMirror = !!(activeEnv && mirrorCandidateKeys?.has(diffKey(entry))) - if (isMirror) pushMirrorHistory() await navigateLocalTo(entry.path) if (isMirror) { const remoteTarget = remotePath === '/' ? `/${entry.name}` : `${remotePath}/${entry.name}` @@ -330,7 +302,6 @@ export default function DualPaneBrowser(): React.JSX.Element { async function navigateRemote(entry: FileEntry): Promise { if (!activeEnv || entry.type !== 'directory') return const isMirror = mirrorCandidateKeys?.has(diffKey(entry)) ?? false - if (isMirror) pushMirrorHistory() await navigateRemoteTo(entry.path) if (isMirror) { const sep = localPath.includes('\\') ? '\\' : '/' @@ -468,13 +439,15 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Local pane */}
{ - if (e.button === 3) { - if (mirrorNav && mirrorBackStack.length > 0) { e.preventDefault(); doMirrorBack() } - else if (localBackStack.length > 0) { e.preventDefault(); void goBackLocal() } + if (e.button === 3 && localBackStack.length > 0) { + e.preventDefault() + void goBackLocal() + if (mirrorNav && pathsMatch) void goBackRemote() } - if (e.button === 4) { - if (mirrorNav && mirrorForwardStack.length > 0) { e.preventDefault(); doMirrorForward() } - else if (localForwardStack.length > 0) { e.preventDefault(); void goForwardLocal() } + if (e.button === 4 && localForwardStack.length > 0) { + e.preventDefault() + void goForwardLocal() + if (mirrorNav && pathsMatch) void goForwardRemote() } }} style={{ @@ -491,7 +464,6 @@ export default function DualPaneBrowser(): React.JSX.Element { path={localPath} label="Local" onNavigateUp={() => { - if (mirrorNav && pathsMatch) pushMirrorHistory() void navigateLocalTo(localParentPath(localPath)) if (mirrorNav && pathsMatch) void navigateRemoteTo(parentPath(remotePath)) }} @@ -504,7 +476,6 @@ export default function DualPaneBrowser(): React.JSX.Element { }} onNavigateTo={(p) => { const steps = pathSegmentCount(localPath) - pathSegmentCount(p) - if (mirrorNav && pathsMatch && steps > 0) pushMirrorHistory() void navigateLocalTo(p) if (mirrorNav && pathsMatch && steps > 0) { let remoteTarget = remotePath @@ -648,13 +619,15 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Remote pane */}
{ - if (e.button === 3) { - if (mirrorNav && mirrorBackStack.length > 0) { e.preventDefault(); doMirrorBack() } - else if (activeEnv && remoteBackStack.length > 0) { e.preventDefault(); void goBackRemote() } + if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { + e.preventDefault() + void goBackRemote() + if (mirrorNav && pathsMatch) void goBackLocal() } - if (e.button === 4) { - if (mirrorNav && mirrorForwardStack.length > 0) { e.preventDefault(); doMirrorForward() } - else if (activeEnv && remoteForwardStack.length > 0) { e.preventDefault(); void goForwardRemote() } + if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { + e.preventDefault() + void goForwardRemote() + if (mirrorNav && pathsMatch) void goForwardLocal() } }} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--pane-bg)' }} @@ -713,7 +686,6 @@ export default function DualPaneBrowser(): React.JSX.Element { label={envLabel(activeEnv)} sublabel={activeEnv.host} onNavigateUp={() => { - if (mirrorNav && pathsMatch) pushMirrorHistory() void navigateRemoteTo(parentPath(remotePath)) if (mirrorNav && pathsMatch) void navigateLocalTo(localParentPath(localPath)) }} @@ -725,7 +697,6 @@ export default function DualPaneBrowser(): React.JSX.Element { onNavigateTo={(displayPath) => { const virtual = displayPath.replace(/^\/Files/, '') || '/' const steps = pathSegmentCount(remotePath) - pathSegmentCount(virtual) - if (mirrorNav && pathsMatch && steps > 0) pushMirrorHistory() void navigateRemoteTo(virtual) if (mirrorNav && pathsMatch && steps > 0) { let localTarget = localPath From 2b1f1c91f4ba42562591ad96094a33ee2a01fa73 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 12:04:36 +0300 Subject: [PATCH 32/55] fix: back/forward always couples both panes when mirror is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the pathsMatch guard from mousedown handlers — when mirrorNav is enabled, back/forward on either pane always moves the other pane too, even if navigating would take them out of sync. Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/src/components/DualPaneBrowser.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 061de79..629bd5c 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -442,12 +442,12 @@ export default function DualPaneBrowser(): React.JSX.Element { if (e.button === 3 && localBackStack.length > 0) { e.preventDefault() void goBackLocal() - if (mirrorNav && pathsMatch) void goBackRemote() + if (mirrorNav) void goBackRemote() } if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault() void goForwardLocal() - if (mirrorNav && pathsMatch) void goForwardRemote() + if (mirrorNav) void goForwardRemote() } }} style={{ @@ -622,12 +622,12 @@ export default function DualPaneBrowser(): React.JSX.Element { if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { e.preventDefault() void goBackRemote() - if (mirrorNav && pathsMatch) void goBackLocal() + if (mirrorNav) void goBackLocal() } if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { e.preventDefault() void goForwardRemote() - if (mirrorNav && pathsMatch) void goForwardLocal() + if (mirrorNav) void goForwardLocal() } }} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--pane-bg)' }} From 735a9b68252f4b68a2b1a84aca6faf56a2fe744c Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 12:05:45 +0300 Subject: [PATCH 33/55] fix: back/forward couples panes only when currently in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores pathsMatch guard — back/forward on either pane also moves the other only when mirror is on AND both panes are currently in sync, preserving the mirrored state after navigation. Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/src/components/DualPaneBrowser.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 629bd5c..061de79 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -442,12 +442,12 @@ export default function DualPaneBrowser(): React.JSX.Element { if (e.button === 3 && localBackStack.length > 0) { e.preventDefault() void goBackLocal() - if (mirrorNav) void goBackRemote() + if (mirrorNav && pathsMatch) void goBackRemote() } if (e.button === 4 && localForwardStack.length > 0) { e.preventDefault() void goForwardLocal() - if (mirrorNav) void goForwardRemote() + if (mirrorNav && pathsMatch) void goForwardRemote() } }} style={{ @@ -622,12 +622,12 @@ export default function DualPaneBrowser(): React.JSX.Element { if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { e.preventDefault() void goBackRemote() - if (mirrorNav) void goBackLocal() + if (mirrorNav && pathsMatch) void goBackLocal() } if (e.button === 4 && activeEnv && remoteForwardStack.length > 0) { e.preventDefault() void goForwardRemote() - if (mirrorNav) void goForwardLocal() + if (mirrorNav && pathsMatch) void goForwardLocal() } }} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--pane-bg)' }} From a73bd1c644697aa04e6e906d4bb596e65e1f8c54 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 12:09:09 +0300 Subject: [PATCH 34/55] fix: deduplicate adjacent entries in back/forward stacks Only push to the back stack when the current path differs from the stack's top, preventing duplicate adjacent entries from mirror-coupled navigation that fires navigateLocalTo and navigateRemoteTo in sequence. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 061de79..e634f25 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -226,7 +226,7 @@ export default function DualPaneBrowser(): React.JSX.Element { // User-initiated navigation: push the current path onto back, clear forward. async function navigateLocalTo(path: string): Promise { if (path === localPath) return - setLocalBackStack((b) => [localPath, ...b]) + setLocalBackStack((b) => b[0] === localPath ? b : [localPath, ...b]) setLocalForwardStack([]) setLocalLoading(true) await loadLocal(path) @@ -258,7 +258,7 @@ export default function DualPaneBrowser(): React.JSX.Element { async function navigateRemoteTo(path: string): Promise { if (!activeEnv || path === remotePath) return - setRemoteBackStack((b) => [remotePath, ...b]) + setRemoteBackStack((b) => b[0] === remotePath ? b : [remotePath, ...b]) setRemoteForwardStack([]) setRemoteLoading(true) await loadRemote(activeEnv.name, path) From 3a059644d09a0d2763499ff4536d42e118098732 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Wed, 29 Apr 2026 12:12:34 +0300 Subject: [PATCH 35/55] fix: restructure go-back/forward to eliminate stale-closure duplicates Reads stack state directly instead of nesting setState inside a functional updater. Adds dedup guards to all forward-stack and back-stack pushes so rapid clicking can't insert the same path twice in a row, which previously caused a refresh instead of a folder switch. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/DualPaneBrowser.tsx | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index e634f25..58474b5 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -235,25 +235,23 @@ export default function DualPaneBrowser(): React.JSX.Element { // Back/forward use the history stacks rather than the parent path. async function goBackLocal(): Promise { - setLocalBackStack((b) => { - if (b.length === 0) return b - const [prev, ...rest] = b - setLocalForwardStack((f) => [localPath, ...f]) - setLocalLoading(true) - void loadLocal(prev).finally(() => setLocalLoading(false)) - return rest - }) + if (localBackStack.length === 0) return + const [prev, ...rest] = localBackStack + setLocalBackStack(rest) + setLocalForwardStack((f) => f[0] === localPath ? f : [localPath, ...f]) + setLocalLoading(true) + await loadLocal(prev) + setLocalLoading(false) } async function goForwardLocal(): Promise { - setLocalForwardStack((f) => { - if (f.length === 0) return f - const [next, ...rest] = f - setLocalBackStack((b) => [localPath, ...b]) - setLocalLoading(true) - void loadLocal(next).finally(() => setLocalLoading(false)) - return rest - }) + if (localForwardStack.length === 0) return + const [next, ...rest] = localForwardStack + setLocalForwardStack(rest) + setLocalBackStack((b) => b[0] === localPath ? b : [localPath, ...b]) + setLocalLoading(true) + await loadLocal(next) + setLocalLoading(false) } async function navigateRemoteTo(path: string): Promise { @@ -266,27 +264,23 @@ export default function DualPaneBrowser(): React.JSX.Element { } async function goBackRemote(): Promise { - if (!activeEnv) return - setRemoteBackStack((b) => { - if (b.length === 0) return b - const [prev, ...rest] = b - setRemoteForwardStack((f) => [remotePath, ...f]) - setRemoteLoading(true) - void loadRemote(activeEnv.name, prev).finally(() => setRemoteLoading(false)) - return rest - }) + if (!activeEnv || remoteBackStack.length === 0) return + const [prev, ...rest] = remoteBackStack + setRemoteBackStack(rest) + setRemoteForwardStack((f) => f[0] === remotePath ? f : [remotePath, ...f]) + setRemoteLoading(true) + await loadRemote(activeEnv.name, prev) + setRemoteLoading(false) } async function goForwardRemote(): Promise { - if (!activeEnv) return - setRemoteForwardStack((f) => { - if (f.length === 0) return f - const [next, ...rest] = f - setRemoteBackStack((b) => [remotePath, ...b]) - setRemoteLoading(true) - void loadRemote(activeEnv.name, next).finally(() => setRemoteLoading(false)) - return rest - }) + if (!activeEnv || remoteForwardStack.length === 0) return + const [next, ...rest] = remoteForwardStack + setRemoteForwardStack(rest) + setRemoteBackStack((b) => b[0] === remotePath ? b : [remotePath, ...b]) + setRemoteLoading(true) + await loadRemote(activeEnv.name, next) + setRemoteLoading(false) } async function navigateLocal(entry: FileEntry): Promise { From 266fc0c7d2e71c13c2be629d65f892409ac11dac Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 10:30:12 +0300 Subject: [PATCH 36/55] feat: striped inactive state on Compare and Mirror buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a button is enabled but pathsMatch is false, the solid accent fill is replaced with a diagonal stripe pattern to signal the feature is on but waiting for matching /Files/… paths. Tooltips updated to explain what's needed to activate. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 35 ++++++++++++++++--- .../src/components/DualPaneBrowser.tsx | 1 + 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 24bdda3..347b74e 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -9,6 +9,7 @@ interface CompareToolbarProps { onToggleStatus: (status: DiffStatus) => void mirrorNav: boolean onMirrorNavChange: (v: boolean) => void + pathsMatch: boolean } const STATUSES: DiffStatus[] = ['different', 'remote-only', 'local-only', 'identical'] @@ -27,6 +28,9 @@ const LABEL: Record = { identical: 'equal' } +const STRIPE = (color: string): string => + `repeating-linear-gradient(45deg, ${color}, ${color} 3px, color-mix(in srgb, ${color} 35%, transparent) 3px, color-mix(in srgb, ${color} 35%, transparent) 7px)` + export default function CompareToolbar({ mode, onModeChange, @@ -34,7 +38,8 @@ export default function CompareToolbar({ highlightedStatuses, onToggleStatus, mirrorNav, - onMirrorNavChange + onMirrorNavChange, + pathsMatch }: CompareToolbarProps): React.JSX.Element { const counts = countByStatus(diffMap) const hasDiff = diffMap.size > 0 @@ -55,7 +60,13 @@ export default function CompareToolbar({ > + {/* Compare button — segmented with eye filter when active */} +
+ + {showFilter && ( + + )} +
{hasDiff && (
diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index bd026df..844c744 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -98,6 +98,7 @@ export default function DualPaneBrowser(): React.JSX.Element { const [remoteBackStack, setRemoteBackStack] = useState([]) const [remoteForwardStack, setRemoteForwardStack] = useState([]) const [pathsMatch, setPathsInSync] = useState(false) + const [filterActive, setFilterActive] = useState(false) const [localMirrorPaths, setLocalMirrorPaths] = useState([]) const [remoteMirrorPaths, setRemoteMirrorPaths] = useState([]) const [contextMenu, setContextMenu] = useState(null) @@ -407,6 +408,13 @@ export default function DualPaneBrowser(): React.JSX.Element { const localSelected = selected.pane === 'local' ? selected.paths : [] const remoteSelected = selected.pane === 'remote' ? selected.paths : [] + const visibleLocalEntries = filterActive && diffMap.size > 0 + ? localEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && highlightedStatuses.includes(s) }) + : localEntries + const visibleRemoteEntries = filterActive && diffMap.size > 0 + ? remoteEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && highlightedStatuses.includes(s) }) + : remoteEntries + const dropZoneStyle: React.CSSProperties = { padding: '7px 12px', borderTop: '1px solid var(--border)', @@ -428,6 +436,8 @@ export default function DualPaneBrowser(): React.JSX.Element { mirrorNav={mirrorNav} onMirrorNavChange={setMirrorNav} pathsMatch={pathsMatch} + filterActive={filterActive} + onFilterChange={setFilterActive} /> )}
@@ -487,7 +497,7 @@ export default function DualPaneBrowser(): React.JSX.Element { setContextMenu({ entry, x, y, pane: 'local' })} @@ -708,7 +718,7 @@ export default function DualPaneBrowser(): React.JSX.Element { setContextMenu({ entry, x, y, pane: 'remote' })} From 7c65867f4e9db33bdc9004d3ab49ce13d0074121 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 11:27:20 +0300 Subject: [PATCH 39/55] feat: Mirror path-match buttons and Compare eye filter polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Local→ / ←Remote arrow buttons to Mirror segmented group; visible when mirror is on, paths are mismatched, and at least one pane is on a /Files/… path; hidden once paths sync - Arrow buttons use original-cased path segments for breadcrumb fidelity - Freeze button styles and expanded controls during navigation using a stable pathsMatch ref to prevent flicker between pane loads Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 152 +++++++++++++----- .../src/components/DualPaneBrowser.tsx | 32 ++++ 2 files changed, 148 insertions(+), 36 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index f3def98..274e4a5 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -1,3 +1,4 @@ +import { useRef } from 'react' import type { CompareMode, DiffStatus } from '../../../shared/types' import { countByStatus } from '../utils/compareEntries' @@ -12,6 +13,11 @@ interface CompareToolbarProps { pathsMatch: boolean filterActive: boolean onFilterChange: (v: boolean) => void + localOnFiles: boolean + remoteOnFiles: boolean + onMatchRemoteToLocal: () => void + onMatchLocalToRemote: () => void + isLoading: boolean } const STATUSES: DiffStatus[] = ['different', 'remote-only', 'local-only', 'identical'] @@ -30,6 +36,24 @@ const LABEL: Record = { identical: 'equal' } +function ArrowIcon({ direction }: { direction: 'left' | 'right' }): React.JSX.Element { + return ( + + {direction === 'right' ? ( + <> + + + + ) : ( + <> + + + + )} + + ) +} + function EyeIcon({ open }: { open: boolean }): React.JSX.Element { return ( @@ -64,16 +88,26 @@ export default function CompareToolbar({ onMirrorNavChange, pathsMatch, filterActive, - onFilterChange + onFilterChange, + localOnFiles, + remoteOnFiles, + onMatchRemoteToLocal, + onMatchLocalToRemote, + isLoading }: CompareToolbarProps): React.JSX.Element { const counts = countByStatus(diffMap) const hasDiff = diffMap.size > 0 const compareOn = mode !== 'off' - const showFilter = compareOn && hasDiff - const compareBorder = compareOn && !pathsMatch ? '1px solid var(--accent)' : '1px solid var(--border-strong)' - const compareBg = compareOn && pathsMatch ? 'var(--accent)' : 'var(--surface-raised)' - const compareColor = compareOn ? (pathsMatch ? '#fff' : 'var(--accent)') : 'var(--text-subtle)' + const stablePathsMatchRef = useRef(pathsMatch) + if (!isLoading) stablePathsMatchRef.current = pathsMatch + const stablePathsMatch = isLoading ? stablePathsMatchRef.current : pathsMatch + const showFilter = compareOn && hasDiff && !isLoading + const showMirrorMatch = mirrorNav && !pathsMatch && (localOnFiles || remoteOnFiles) && !isLoading + + const compareBorder = compareOn && !stablePathsMatch ? '1px solid var(--accent)' : '1px solid var(--border-strong)' + const compareBg = compareOn && stablePathsMatch ? 'var(--accent)' : 'var(--surface-raised)' + const compareColor = compareOn ? (stablePathsMatch ? '#fff' : 'var(--accent)') : 'var(--text-subtle)' return (
+ {/* Mirror button — segmented with path-match arrows when active but unsynced */} +
+ + {showMirrorMatch && ( + <> + + + + )} +
+ + + {/* Compare button — segmented with eye filter when active */}
) } diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 844c744..e12d9b1 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -284,6 +284,30 @@ export default function DualPaneBrowser(): React.JSX.Element { setRemoteLoading(false) } + function matchRemoteToLocal(): void { + // Navigate remote to match local's /Files/… path — extract original-cased segments + const norm = localPath.replace(/\\/g, '/') + const parts = norm.split('/') + const filesIdx = parts.findIndex((s) => s.toLowerCase() === 'files') + if (filesIdx === -1) return + const relParts = parts.slice(filesIdx + 1).filter(Boolean) + void navigateRemoteTo(relParts.length > 0 ? '/' + relParts.join('/') : '/') + } + + function matchLocalToRemote(): void { + // Navigate local to match remote's /Files/… path — remotePath already has correct API casing + const norm = localPath.replace(/\\/g, '/') + const parts = norm.split('/') + const filesIdx = parts.findIndex((s) => s.toLowerCase() === 'files') + if (filesIdx === -1) return + const sep = localPath.includes('\\') ? '\\' : '/' + const base = parts.slice(0, filesIdx + 1).join('/') + const relParts = remotePath.split('/').filter(Boolean) + const newLocal = (sep === '\\' ? base.replace(/\//g, '\\') : base) + + (relParts.length > 0 ? sep + relParts.join(sep) : '') + void navigateLocalTo(newLocal) + } + async function navigateLocal(entry: FileEntry): Promise { if (entry.type !== 'directory') return const isMirror = !!(activeEnv && mirrorCandidateKeys?.has(diffKey(entry))) @@ -408,6 +432,9 @@ export default function DualPaneBrowser(): React.JSX.Element { const localSelected = selected.pane === 'local' ? selected.paths : [] const remoteSelected = selected.pane === 'remote' ? selected.paths : [] + const localOnFiles = !!getDwRelativeTail(localPath) + const remoteOnFiles = !!getDwRelativeTail(toDisplayRemotePath(remotePath)) + const visibleLocalEntries = filterActive && diffMap.size > 0 ? localEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && highlightedStatuses.includes(s) }) : localEntries @@ -438,6 +465,11 @@ export default function DualPaneBrowser(): React.JSX.Element { pathsMatch={pathsMatch} filterActive={filterActive} onFilterChange={setFilterActive} + localOnFiles={localOnFiles} + remoteOnFiles={remoteOnFiles} + onMatchRemoteToLocal={matchRemoteToLocal} + onMatchLocalToRemote={matchLocalToRemote} + isLoading={localLoading || remoteLoading} /> )}
From cf5dc1ccf5dc1c167834d1f1217dee30942205bb Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 11:33:31 +0300 Subject: [PATCH 40/55] feat: expand/collapse animation for toolbar pills and sub-buttons Wraps eye filter, diff pills, and mirror arrow buttons in an Expand component that transitions max-width + opacity on show/hide (140ms ease-out in, 80ms ease-in out). Fixes toolbar height flicker by using a fixed height instead of minHeight. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 126 ++++++++++-------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 274e4a5..c35e189 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -20,6 +20,24 @@ interface CompareToolbarProps { isLoading: boolean } +function Expand({ show, children }: { show: boolean; children: React.ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + const STATUSES: DiffStatus[] = ['different', 'remote-only', 'local-only', 'identical'] const PILL_COLOR: Record = { @@ -119,7 +137,7 @@ export default function CompareToolbar({ borderBottom: '1px solid var(--border)', background: 'var(--surface)', flexShrink: 0, - minHeight: 28 + height: 32 }} > {/* Mirror button — segmented with path-match arrows when active but unsynced */} @@ -144,56 +162,54 @@ export default function CompareToolbar({ > ⇄ Mirror - {showMirrorMatch && ( - <> - - - - )} + + + +
@@ -220,7 +236,7 @@ export default function CompareToolbar({ > ⊟ Compare - {showFilter && ( + - )} +
- {hasDiff && ( +
{STATUSES.map((status) => { const count = counts[status] @@ -289,7 +305,7 @@ export default function CompareToolbar({ ) })}
- )} +
) } From 46978cf22a8c19892931dda60cfeb34d783a2f8a Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 12:08:51 +0300 Subject: [PATCH 41/55] feat: compare toolbar polish and back/forward history improvements - Freeze diffMap, hasDiff, and pathsMatch during loading to eliminate toolbar flicker during navigation - Compare expand animation only fires on button click, not on other state changes - Eye filter is pane-aware: local pane ignores remote-only pill, remote pane ignores local-only pill - Clear back/forward stacks when mirror mode activates (pathsMatch transitions to true) - Remove back button fallback (go-up-a-folder) to prevent ping-pong loop - Arrow match buttons visible regardless of mirror toggle state - matchLocalToRemote falls back to localStartPath when current path has no /Files/ segment Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/CompareToolbar.tsx | 41 +++++++++---- .../src/components/DualPaneBrowser.tsx | 59 ++++++++++++++----- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index c35e189..aa16ba9 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import type { CompareMode, DiffStatus } from '../../../shared/types' import { countByStatus } from '../utils/compareEntries' @@ -20,7 +20,7 @@ interface CompareToolbarProps { isLoading: boolean } -function Expand({ show, children }: { show: boolean; children: React.ReactNode }): React.JSX.Element { +function Expand({ show, animate, children }: { show: boolean; animate?: boolean; children: React.ReactNode }): React.JSX.Element { return (
{children} @@ -113,15 +113,32 @@ export default function CompareToolbar({ onMatchLocalToRemote, isLoading }: CompareToolbarProps): React.JSX.Element { - const counts = countByStatus(diffMap) - const hasDiff = diffMap.size > 0 + const stableDiffMapRef = useRef(diffMap) + if (!isLoading) stableDiffMapRef.current = diffMap + const stableDiffMap = isLoading ? stableDiffMapRef.current : diffMap + + const counts = countByStatus(stableDiffMap) + const hasDiff = stableDiffMap.size > 0 const compareOn = mode !== 'off' + const [compareAnimate, setCompareAnimate] = useState(false) + const compareAnimateTimer = useRef | null>(null) + function triggerCompareAnimation(): void { + if (compareAnimateTimer.current) clearTimeout(compareAnimateTimer.current) + setCompareAnimate(true) + compareAnimateTimer.current = setTimeout(() => setCompareAnimate(false), 300) + } + const stablePathsMatchRef = useRef(pathsMatch) if (!isLoading) stablePathsMatchRef.current = pathsMatch const stablePathsMatch = isLoading ? stablePathsMatchRef.current : pathsMatch - const showFilter = compareOn && hasDiff && !isLoading - const showMirrorMatch = mirrorNav && !pathsMatch && (localOnFiles || remoteOnFiles) && !isLoading + + const stableHasDiffRef = useRef(hasDiff) + if (!isLoading) stableHasDiffRef.current = hasDiff + const stableHasDiff = isLoading ? stableHasDiffRef.current : hasDiff + + const showFilter = compareOn && stableHasDiff + const showMirrorMatch = !stablePathsMatch && (localOnFiles || remoteOnFiles) && !isLoading const compareBorder = compareOn && !stablePathsMatch ? '1px solid var(--accent)' : '1px solid var(--border-strong)' const compareBg = compareOn && stablePathsMatch ? 'var(--accent)' : 'var(--surface-raised)' @@ -225,7 +242,7 @@ export default function CompareToolbar({ : 'Compare is enabled — navigate both panes to a matching /Files/… folder to activate' : 'Enable compare — detects differences by comparing file sizes' } - onClick={() => onModeChange(compareOn ? 'off' : 'auto')} + onClick={() => { triggerCompareAnimation(); onModeChange(compareOn ? 'off' : 'auto') }} style={{ ...segBase, background: compareBg, @@ -236,7 +253,7 @@ export default function CompareToolbar({ > ⊟ Compare - +
- +
{STATUSES.map((status) => { const count = counts[status] diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index e12d9b1..ca384f1 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -206,6 +206,19 @@ export default function DualPaneBrowser(): React.JSX.Element { } }, [compareMode, localEntries, remoteEntries, localPath, remotePath, setDiffMap]) + const mirrorActiveRef = useRef(false) + useEffect(() => { + if (localLoading || remoteLoading) return + const active = mirrorNav && pathsMatch + if (active && !mirrorActiveRef.current) { + setLocalBackStack([]) + setLocalForwardStack([]) + setRemoteBackStack([]) + setRemoteForwardStack([]) + } + mirrorActiveRef.current = active + }, [mirrorNav, pathsMatch, localLoading, remoteLoading]) + const mirrorCandidateKeys = useMemo(() => { if (!pathsMatch || !mirrorNav) return null if (diffMap.size > 0) { @@ -294,17 +307,27 @@ export default function DualPaneBrowser(): React.JSX.Element { void navigateRemoteTo(relParts.length > 0 ? '/' + relParts.join('/') : '/') } + function getLocalFilesBase(): { base: string; sep: string } | null { + // Try current localPath first, then fall back to localStartPath from env settings + for (const candidate of [localPath, activeEnv?.localStartPath ?? '']) { + if (!candidate) continue + const norm = candidate.replace(/\\/g, '/') + const parts = norm.split('/') + const idx = parts.findIndex((s) => s.toLowerCase() === 'files') + if (idx === -1) continue + const sep = candidate.includes('\\') ? '\\' : '/' + const base = parts.slice(0, idx + 1).join('/') + return { base: sep === '\\' ? base.replace(/\//g, '\\') : base, sep } + } + return null + } + function matchLocalToRemote(): void { - // Navigate local to match remote's /Files/… path — remotePath already has correct API casing - const norm = localPath.replace(/\\/g, '/') - const parts = norm.split('/') - const filesIdx = parts.findIndex((s) => s.toLowerCase() === 'files') - if (filesIdx === -1) return - const sep = localPath.includes('\\') ? '\\' : '/' - const base = parts.slice(0, filesIdx + 1).join('/') + const filesBase = getLocalFilesBase() + if (!filesBase) return + const { base, sep } = filesBase const relParts = remotePath.split('/').filter(Boolean) - const newLocal = (sep === '\\' ? base.replace(/\//g, '\\') : base) + - (relParts.length > 0 ? sep + relParts.join(sep) : '') + const newLocal = base + (relParts.length > 0 ? sep + relParts.join(sep) : '') void navigateLocalTo(newLocal) } @@ -433,13 +456,17 @@ export default function DualPaneBrowser(): React.JSX.Element { const remoteSelected = selected.pane === 'remote' ? selected.paths : [] const localOnFiles = !!getDwRelativeTail(localPath) - const remoteOnFiles = !!getDwRelativeTail(toDisplayRemotePath(remotePath)) + const remoteOnFiles = !!getDwRelativeTail(toDisplayRemotePath(remotePath)) && !!( + getDwRelativeTail(localPath) || (activeEnv?.localStartPath ? getDwRelativeTail(activeEnv.localStartPath) : null) + ) - const visibleLocalEntries = filterActive && diffMap.size > 0 - ? localEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && highlightedStatuses.includes(s) }) + const localFilterStatuses = highlightedStatuses.filter((s) => s !== 'remote-only') + const remoteFilterStatuses = highlightedStatuses.filter((s) => s !== 'local-only') + const visibleLocalEntries = filterActive && diffMap.size > 0 && localFilterStatuses.length > 0 + ? localEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && localFilterStatuses.includes(s) }) : localEntries - const visibleRemoteEntries = filterActive && diffMap.size > 0 - ? remoteEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && highlightedStatuses.includes(s) }) + const visibleRemoteEntries = filterActive && diffMap.size > 0 && remoteFilterStatuses.length > 0 + ? remoteEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && remoteFilterStatuses.includes(s) }) : remoteEntries const dropZoneStyle: React.CSSProperties = { @@ -476,7 +503,7 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Local pane */}
{ - if (e.button === 3 && localBackStack.length > 0) { + if (e.button === 3) { e.preventDefault() void goBackLocal() if (mirrorNav && pathsMatch) void goBackRemote() @@ -656,7 +683,7 @@ export default function DualPaneBrowser(): React.JSX.Element { {/* Remote pane */}
{ - if (e.button === 3 && activeEnv && remoteBackStack.length > 0) { + if (e.button === 3 && activeEnv) { e.preventDefault() void goBackRemote() if (mirrorNav && pathsMatch) void goBackLocal() From f8ac0196f2a671194700b68b389041d36faea743 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 12:38:15 +0300 Subject: [PATCH 42/55] feat: breadcrumb path editor, mirror navigation polish, and UI feedback improvements - Add pencil button to PaneHeader that replaces breadcrumb with editable input; Enter navigates, Escape/blur cancels; pre-fills with forward-slash normalised path; empty/slash navigates local to drives view - Mirror-aware onNavigateTo: typed local paths inside /Files mirror to remote; typed remote paths use localStartPath base to mirror to local - matchLocalToRemote falls back to localStartPath if computed subfolder does not exist - getLocalFilesBase: if localStartPath has no /files segment, append /Files as base for backwards compatibility with older installations - remoteOnFiles: gate only requires localStartPath to be set, not that it contains /files - Remove step-based remote mirror fallback from local breadcrumb clicks above /Files - Fix showMirrorMatch flicker: remove !isLoading guard (stablePathsMatch already freezes during load) - Guard matchRemoteToLocal and matchLocalToRemote against concurrent calls while loading - Arrow buttons always use outlined accent style; dimmed at 35% opacity when disabled - Add toolbar-btn CSS class (brightness filter) for hover/click feedback on Compare, Mirror, Theme, and pill buttons - Add nav-btn CSS class for hover/click feedback on top navigation tabs - Fix browse button opening multiple dialogs: pickingRef guard in AddEnvModal and EditEnvModal - Breadcrumb always uses forward-slash separator for display and path input - Normalise separator in back/forward stack deduplication guards Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/App.tsx | 2 +- dw-desktop/src/renderer/src/assets/main.css | 17 ++++ .../renderer/src/components/AddEnvModal.tsx | 15 ++- .../src/components/CompareToolbar.tsx | 20 ++-- .../src/components/DualPaneBrowser.tsx | 80 +++++++++++----- .../renderer/src/components/EditEnvModal.tsx | 15 ++- .../renderer/src/components/PaneHeader.tsx | 93 ++++++++++++++++++- .../renderer/src/components/ThemeSwitcher.tsx | 1 + 8 files changed, 200 insertions(+), 43 deletions(-) diff --git a/dw-desktop/src/renderer/src/App.tsx b/dw-desktop/src/renderer/src/App.tsx index 8732fd9..81b451e 100644 --- a/dw-desktop/src/renderer/src/App.tsx +++ b/dw-desktop/src/renderer/src/App.tsx @@ -85,7 +85,7 @@ export default function App(): React.JSX.Element { key={t} type="button" onClick={() => setTab(t)} - className="no-drag" + className="no-drag nav-btn" style={{ padding: '5px 10px', fontSize: 12, diff --git a/dw-desktop/src/renderer/src/assets/main.css b/dw-desktop/src/renderer/src/assets/main.css index c80d1cf..c8514e9 100644 --- a/dw-desktop/src/renderer/src/assets/main.css +++ b/dw-desktop/src/renderer/src/assets/main.css @@ -144,6 +144,23 @@ input:not(:focus-visible) { outline: none; } -webkit-app-region: no-drag; } +/* Top navigation tab button (Files, Transfer log, Debug, …) */ +.nav-btn:hover { + background: var(--control-hover) !important; + color: var(--text) !important; +} +.nav-btn:active { + background: var(--surface-raised) !important; +} + +/* Segmented toolbar button (Compare, Mirror, Theme, pills) */ +.toolbar-btn:hover:not([disabled]) { + filter: brightness(1.12); +} +.toolbar-btn:active:not([disabled]) { + filter: brightness(0.88); +} + /* Small icon button (pane headers, toolbars) */ .icon-btn { display: inline-flex; diff --git a/dw-desktop/src/renderer/src/components/AddEnvModal.tsx b/dw-desktop/src/renderer/src/components/AddEnvModal.tsx index 8ecae9b..72ca117 100644 --- a/dw-desktop/src/renderer/src/components/AddEnvModal.tsx +++ b/dw-desktop/src/renderer/src/components/AddEnvModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useEnvStore } from '../stores/envStore' import type { StoredEnv } from '../../../shared/types' @@ -97,10 +97,17 @@ export default function AddEnvModal({ onDone }: AddEnvModalProps): React.JSX.Ele } } + const pickingRef = useRef(false) async function pickLocalStartPath(): Promise { - const result = await window.dw.fs.openDialog(['openDirectory']) - if (result.ok && result.data && result.data.paths.length > 0) { - setStep1((s) => ({ ...s, localStartPath: result.data!.paths[0] })) + if (pickingRef.current) return + pickingRef.current = true + try { + const result = await window.dw.fs.openDialog(['openDirectory']) + if (result.ok && result.data && result.data.paths.length > 0) { + setStep1((s) => ({ ...s, localStartPath: result.data!.paths[0] })) + } + } finally { + pickingRef.current = false } } diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index aa16ba9..62be7f7 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -138,7 +138,7 @@ export default function CompareToolbar({ const stableHasDiff = isLoading ? stableHasDiffRef.current : hasDiff const showFilter = compareOn && stableHasDiff - const showMirrorMatch = !stablePathsMatch && (localOnFiles || remoteOnFiles) && !isLoading + const showMirrorMatch = !stablePathsMatch && (localOnFiles || remoteOnFiles) const compareBorder = compareOn && !stablePathsMatch ? '1px solid var(--accent)' : '1px solid var(--border-strong)' const compareBg = compareOn && stablePathsMatch ? 'var(--accent)' : 'var(--surface-raised)' @@ -160,6 +160,7 @@ export default function CompareToolbar({ {/* Mirror button — segmented with path-match arrows when active but unsynced */}
+ )} From 5e3f4e9b9097a1749ee0282cec385ce27b0ffe7b Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 15:30:42 +0300 Subject: [PATCH 45/55] fix: up button disabled at pane root, eye filter ignores invisible pills - Pass upDisabled to both pane headers: local disables at drives view, remote disables at virtual root (remotePath === '/') - Eye filter now only counts highlighted statuses that have at least one entry in the diffMap (diffCounts[s] > 0), so deselecting all visible pills falls through to showing all files rather than empty results Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/src/components/DualPaneBrowser.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 11d91d1..af2b454 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -5,7 +5,7 @@ import { useEnvStore } from '../stores/envStore' import { useFileStore } from '../stores/fileStore' import { useToastStore } from '../stores/toastStore' import { useTransferStore } from '../stores/transferStore' -import { compareEntries, diffKey, getDwRelativeTail } from '../utils/compareEntries' +import { compareEntries, countByStatus, diffKey, getDwRelativeTail } from '../utils/compareEntries' import AddEnvModal from './AddEnvModal' import CompareToolbar from './CompareToolbar' import ContextMenu from './ContextMenu' @@ -487,8 +487,9 @@ export default function DualPaneBrowser(): React.JSX.Element { !!getDwRelativeTail(localPath) || !!activeEnv?.localStartPath ) - const localFilterStatuses = highlightedStatuses.filter((s) => s !== 'remote-only') - const remoteFilterStatuses = highlightedStatuses.filter((s) => s !== 'local-only') + const diffCounts = countByStatus(diffMap) + const localFilterStatuses = highlightedStatuses.filter((s) => s !== 'remote-only' && diffCounts[s] > 0) + const remoteFilterStatuses = highlightedStatuses.filter((s) => s !== 'local-only' && diffCounts[s] > 0) const visibleLocalEntries = filterActive && diffMap.size > 0 && localFilterStatuses.length > 0 ? localEntries.filter((e) => { const s = diffMap.get(diffKey(e)); return s !== undefined && localFilterStatuses.includes(s) }) : localEntries @@ -554,6 +555,7 @@ export default function DualPaneBrowser(): React.JSX.Element { { void navigateLocalTo(localParentPath(localPath)) if (mirrorNav && pathsMatch) void navigateRemoteTo(parentPath(remotePath)) @@ -777,6 +779,7 @@ export default function DualPaneBrowser(): React.JSX.Element { path={toDisplayRemotePath(remotePath)} label={envLabel(activeEnv)} sublabel={activeEnv.host} + upDisabled={remotePath === '/'} onNavigateUp={() => { void navigateRemoteTo(parentPath(remotePath)) if (mirrorNav && pathsMatch) void navigateLocalTo(localParentPath(localPath)) From d361feab717dccce193c34135e088dc773b748b5 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 15:42:28 +0300 Subject: [PATCH 46/55] fix: remove upDisabled from local pane (only remote root needs it) upDisabled={!localPath} disabled the local up button whenever localPath was '' (drives view), which is the Windows top-level state. The up button should remain clickable in all local pane states; the disabled behavior was only requested for the remote pane root. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index af2b454..06c5e80 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -555,7 +555,6 @@ export default function DualPaneBrowser(): React.JSX.Element { { void navigateLocalTo(localParentPath(localPath)) if (mirrorNav && pathsMatch) void navigateRemoteTo(parentPath(remotePath)) From 5caf07e5f760016f1b8c0e13045f0e4dcb285d68 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 15:47:31 +0300 Subject: [PATCH 47/55] fix: localParentPath breaks on forward-slash Windows paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After breadcrumb changes store paths with forward slashes (D:/a/b), localParentPath detected 'Windows' by checking path.includes('\'). Forward-slash paths got sep='/' and returned '/D:/a' with a bogus leading slash — loadLocal failed silently, making the up button appear stuck until the user double-clicked a folder entry (restoring backslash). Fix: split on both separators, and detect Windows by drive-letter pattern rather than separator character. Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/src/components/DualPaneBrowser.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx index 06c5e80..2095239 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -52,19 +52,22 @@ function localParentPath(path: string): string { // Unix root has no parent. if (path === '/') return '/' + // Prefer backslash as sep if present; otherwise forward slash. const sep = path.includes('\\') ? '\\' : '/' - const parts = path.split(sep).filter(Boolean) + // Split on either separator so forward-slash Windows paths (D:/a/b) work too. + const parts = path.split(/[/\\]/).filter(Boolean) parts.pop() if (parts.length === 0) { - // Walked off a Unix path → root. Walked off a relative path → drives. return sep === '/' ? '/' : '' } // Drive letter alone needs a trailing separator to be listable. if (parts.length === 1 && /^[A-Za-z]:$/.test(parts[0])) { return parts[0] + sep } - return sep === '/' ? '/' + parts.join('/') : parts.join(sep) + // Detect Windows by drive letter — NOT by separator — so D:/a/b is handled correctly. + const isWindows = /^[A-Za-z]:/.test(path) + return isWindows ? parts.join(sep) : '/' + parts.join('/') } export default function DualPaneBrowser(): React.JSX.Element { From 6f460c3ac78669755dfb883a1051bd626c8aaae9 Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 15:50:42 +0300 Subject: [PATCH 48/55] feat: disable remote-to-local arrow when local path does not exist When navigating to a remote folder that has no matching local counterpart, the left mirror arrow now greys out and is non-clickable. DualPaneBrowser checks the would-be local target path with fs.list whenever remotePath or localPath changes and passes the result as matchLocalPathExists to CompareToolbar. Tooltip updated to explain the disabled state. Co-Authored-By: Claude Sonnet 4.6 --- .../src/renderer/src/components/CompareToolbar.tsx | 14 ++++++++++---- .../renderer/src/components/DualPaneBrowser.tsx | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx index 62be7f7..7693ff2 100644 --- a/dw-desktop/src/renderer/src/components/CompareToolbar.tsx +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -15,6 +15,7 @@ interface CompareToolbarProps { onFilterChange: (v: boolean) => void localOnFiles: boolean remoteOnFiles: boolean + matchLocalPathExists: boolean onMatchRemoteToLocal: () => void onMatchLocalToRemote: () => void isLoading: boolean @@ -109,6 +110,7 @@ export default function CompareToolbar({ onFilterChange, localOnFiles, remoteOnFiles, + matchLocalPathExists, onMatchRemoteToLocal, onMatchLocalToRemote, isLoading @@ -208,9 +210,13 @@ export default function CompareToolbar({ From 261557e40cff53fa775d00900dd0a955c73289cc Mon Sep 17 00:00:00 2001 From: Aleksandar Ivanov Date: Thu, 30 Apr 2026 17:57:35 +0300 Subject: [PATCH 55/55] =?UTF-8?q?feat:=20responsive=20pane=20header=20?= =?UTF-8?q?=E2=80=94=20env=20name=20compresses=20to=20give=20breadcrumb=20?= =?UTF-8?q?space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Label and host now shrink with ellipsis at narrow widths; breadcrumb has an 80px minimum so the current folder is always visible. Nav buttons remain fixed and never compress. Co-Authored-By: Claude Sonnet 4.6 --- dw-desktop/src/renderer/src/components/PaneHeader.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/dw-desktop/src/renderer/src/components/PaneHeader.tsx b/dw-desktop/src/renderer/src/components/PaneHeader.tsx index 922906e..591ecac 100644 --- a/dw-desktop/src/renderer/src/components/PaneHeader.tsx +++ b/dw-desktop/src/renderer/src/components/PaneHeader.tsx @@ -66,7 +66,7 @@ function Breadcrumb({ fontSize: 11, fontFamily: 'var(--font-mono)', flex: 1, - minWidth: 0, + minWidth: 80, overflow: 'hidden', whiteSpace: 'nowrap' }} @@ -215,8 +215,9 @@ export default function PaneHeader({ display: 'flex', alignItems: 'baseline', gap: 6, - flexShrink: 0, - minWidth: 0 + flexShrink: 1, + minWidth: 0, + overflow: 'hidden' }} >