diff --git a/README.md b/README.md index adcff1e..91d9ee0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Get the latest version from the [Releases page](https://github.com/dynamicweb/dw - Move files between your local machine and DW10 environments - Browse and manage remote files visually +- Compare local and remote folder contents at a glance and filter to only files that differ - Work across multiple environments (dev, staging, production) - Handle large transfers with progress tracking and logs @@ -35,6 +36,12 @@ Get the latest version from the [Releases page](https://github.com/dynamicweb/dw - **Dual-pane file browser** Local files on one side, remote environment on the other +- **Mirror navigation** + Navigate both panes together when folder structures match; arrow buttons snap either pane to the other's path + +- **Compare mode** + Colour-coded diff between local and remote folders — different, local-only, remote-only, identical — with per-status filters + - **Drag & drop transfers** Upload and download files with real-time progress diff --git a/dw-desktop/TODOS-ForReview.md b/dw-desktop/TODOS-ForReview.md index 1a16227..16f80e2 100644 --- a/dw-desktop/TODOS-ForReview.md +++ b/dw-desktop/TODOS-ForReview.md @@ -45,37 +45,6 @@ handling. --- -### Folder compare view - -**What:** A side-by-side diff view that highlights which files differ between the -local and remote pane (by name, size, modified date). Color-code: only-on-left, -only-on-right, both-but-different, identical. - -**Why:** This is the single most common question a partner asks during deployment: -"is my remote actually up to date with my local build?" Today there is no answer -without manually downloading and diffing. - -**Pros:** Pure client-side feature on already-listed entries. Acts as a direct -precursor to folder sync — once you can compare, sync is the obvious follow-up. - -**Cons:** No content hashing in the API — comparison is limited to name, -`sizeInBytes`, and `updatedAt` (all confirmed returned by `AssetsByDirectory`). -This is sufficient for typical deployment validation but won't catch a file -modified and then reverted to the exact same byte size. - -**Context:** The existing dual-pane layout is structurally perfect for this — the -comparison is a styling overlay on existing FileList rows. All required metadata -(`sizeInBytes`, `updatedAt`) is already fetched and stored in `FileEntry`. - -**Value:** Very High — directly answers the primary deployment question; transforms -the app from "file browser" to "deployment tool". - -**Difficulty:** Low-Medium — all the data already exists in loaded `FileEntry` -arrays; no new API calls needed. The work is purely visual (diff overlay on -FileList rows) plus straightforward name+size+mtime comparison logic. - ---- - ### Keyboard shortcuts **What:** Add shortcuts for common actions: upload (Cmd/Ctrl+U), download diff --git a/dw-desktop/src/main/ipc/files.ts b/dw-desktop/src/main/ipc/files.ts index a8b7d0b..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 } } }) ) @@ -35,6 +43,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 +136,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/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 new file mode 100644 index 0000000..7693ff2 --- /dev/null +++ b/dw-desktop/src/renderer/src/components/CompareToolbar.tsx @@ -0,0 +1,340 @@ +import { useRef, useState } from 'react' +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 + mirrorNav: boolean + onMirrorNavChange: (v: boolean) => void + pathsMatch: boolean + filterActive: boolean + onFilterChange: (v: boolean) => void + localOnFiles: boolean + remoteOnFiles: boolean + matchLocalPathExists: boolean + onMatchRemoteToLocal: () => void + onMatchLocalToRemote: () => void + isLoading: boolean +} + +function Expand({ show, animate, children }: { show: boolean; animate?: boolean; children: React.ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +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', + 'local-only': 'local', + identical: 'equal' +} + +function ArrowIcon({ direction }: { direction: 'left' | 'right' }): React.JSX.Element { + return ( + + {direction === 'right' ? ( + <> + + + + ) : ( + <> + + + + )} + + ) +} + +function EyeIcon({ open }: { open: boolean }): React.JSX.Element { + return ( + + + + {!open && ( + + )} + + ) +} + +const segBase: React.CSSProperties = { + height: 22, + padding: '0 8px', + fontSize: 10, + fontFamily: 'var(--font-ui)', + letterSpacing: '0.04em', + textTransform: 'uppercase', + cursor: 'pointer', + userSelect: 'none', + transition: 'background 80ms ease-out, color 80ms ease-out', +} + +export default function CompareToolbar({ + mode, + onModeChange, + diffMap, + highlightedStatuses, + onToggleStatus, + mirrorNav, + onMirrorNavChange, + pathsMatch, + filterActive, + onFilterChange, + localOnFiles, + remoteOnFiles, + matchLocalPathExists, + onMatchRemoteToLocal, + onMatchLocalToRemote, + isLoading +}: CompareToolbarProps): React.JSX.Element { + 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 stableHasDiffRef = useRef(hasDiff) + if (!isLoading) stableHasDiffRef.current = hasDiff + const stableHasDiff = isLoading ? stableHasDiffRef.current : hasDiff + + const showFilter = compareOn && stableHasDiff + 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)' + const compareColor = compareOn ? (stablePathsMatch ? '#fff' : 'var(--accent)') : 'var(--text-subtle)' + + return ( +
+ {/* Mirror button — segmented with path-match arrows when active but unsynced */} +
+ + + + + +
+ + + + {/* Compare button — segmented with eye filter when active */} +
+ + + + +
+ + +
+ {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..e218d88 100644 --- a/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx +++ b/dw-desktop/src/renderer/src/components/DualPaneBrowser.tsx @@ -1,11 +1,13 @@ 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, countByStatus, diffKey, getDwRelativeTail } from '../utils/compareEntries' import AddEnvModal from './AddEnvModal' +import CompareToolbar from './CompareToolbar' import ContextMenu from './ContextMenu' import FileList from './FileList' import PaneHeader from './PaneHeader' @@ -39,12 +41,33 @@ 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. + if (/^[A-Za-z]:[\\/]?$/.test(path)) return '' + // 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) - if (parts.length <= 1) return path - const parent = parts.slice(0, -1).join(sep) - return parent || path + // 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) { + 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 + } + // 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 { @@ -56,17 +79,31 @@ export default function DualPaneBrowser(): React.JSX.Element { localEntries, localPath, selected, + compareMode, + diffMap, + highlightedStatuses, + mirrorNav, loadRemote, loadLocal, - setSelected + setSelected, + setCompareMode, + setDiffMap, + setMirrorNav, + toggleHighlightedStatus } = useFileStore() const { addJob } = useTransferStore() const showToast = useToastStore((s) => s.show) 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 [pathsMatch, setPathsInSync] = useState(false) + const [filterActive, setFilterActive] = 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) @@ -125,9 +162,14 @@ export default function DualPaneBrowser(): React.JSX.Element { paneResult.data?.localPath ?? activeEnv.localStartPath ?? (await window.dw.fs.homedir()) await loadRemote(activeEnv.name, savedRemote) if (cancelled) return - if (resolvedLocal && resolvedLocal !== useFileStore.getState().localPath) { + const norm = (p: string): string => p.replace(/\\/g, '/') + if (resolvedLocal && norm(resolvedLocal) !== norm(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(() => { @@ -151,22 +193,205 @@ export default function DualPaneBrowser(): React.JSX.Element { setConflictCount(conflicts.length) }, [selected, remoteEntries]) - async function navigateLocal(entry: FileEntry): Promise { - if (entry.type !== 'directory') return + useEffect(() => { + const localTail = getDwRelativeTail(localPath) + const remoteTail = getDwRelativeTail(toDisplayRemotePath(remotePath)) + const tailsMatch = !!(localTail && remoteTail && localTail === remoteTail) + setPathsInSync(tailsMatch) + + if (compareMode === 'off') { + setDiffMap(new Map()) + return + } + if (compareMode === 'on' || tailsMatch) { + setDiffMap(compareEntries(localEntries, remoteEntries)) + } else { + setDiffMap(new Map()) + } + }, [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) { + const keys = new Set() + for (const [key, status] of diffMap) { + if (status === 'identical' || status === 'different') keys.add(key) + } + return keys + } + // Compare is off — compute folder matches directly from entries + const remoteKeys = new Set(remoteEntries.filter((e) => e.type === 'directory').map((e) => diffKey(e))) + const keys = new Set() + for (const e of localEntries) { + if (e.type === 'directory' && remoteKeys.has(diffKey(e))) keys.add(diffKey(e)) + } + return keys + }, [diffMap, pathsMatch, mirrorNav, localEntries, remoteEntries]) + + // User-initiated navigation: push the current path onto back, clear forward. + const normLocal = (p: string): string => p.replace(/\\/g, '/') + + async function navigateLocalTo(path: string): Promise { + if (normLocal(path) === normLocal(localPath)) return true + setLocalBackStack((b) => normLocal(b[0] ?? '') === normLocal(localPath) ? b : [localPath, ...b]) setLocalForwardStack([]) setLocalLoading(true) - await loadLocal(entry.path) + const ok = await loadLocal(path) setLocalLoading(false) + return ok } - 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 { + if (localBackStack.length === 0) return + const [prev, ...rest] = localBackStack + setLocalBackStack(rest) + setLocalForwardStack((f) => normLocal(f[0] ?? '') === normLocal(localPath) ? f : [localPath, ...f]) + setLocalLoading(true) + await loadLocal(prev) + setLocalLoading(false) + } + + async function goForwardLocal(): Promise { + if (localForwardStack.length === 0) return + const [next, ...rest] = localForwardStack + setLocalForwardStack(rest) + setLocalBackStack((b) => normLocal(b[0] ?? '') === normLocal(localPath) ? b : [localPath, ...b]) + setLocalLoading(true) + await loadLocal(next) + setLocalLoading(false) + } + + async function navigateRemoteTo(path: string): Promise { + if (!activeEnv || path === remotePath) return + setRemoteBackStack((b) => b[0] === remotePath ? 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 || 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 || 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) + } + + function matchRemoteToLocal(): void { + if (localLoading || remoteLoading) return + // 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 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') + const sep = candidate.includes('\\') ? '\\' : '/' + let base: string + if (idx !== -1) { + base = parts.slice(0, idx + 1).join('/') + } else if (candidate === (activeEnv?.localStartPath ?? '')) { + // localStartPath doesn't include a Files segment — treat it as the parent and append Files + base = norm.replace(/\/$/, '') + '/Files' + } else { + continue + } + return { base: sep === '\\' ? base.replace(/\//g, '\\') : base, sep } + } + return null + } + + function getFilesRelativeParts(p: string): string[] | null { + const norm = p.replace(/\\/g, '/') + const parts = norm.split('/') + const idx = parts.findIndex((s) => s.toLowerCase() === 'files') + if (idx === -1) return null + return parts.slice(idx + 1).filter(Boolean) + } + + function matchLocalToRemote(): void { + if (localLoading || remoteLoading) return + const filesBase = getLocalFilesBase() + if (!filesBase) return + const { base, sep } = filesBase + const relParts = remotePath.split('/').filter(Boolean) + const newLocal = base + (relParts.length > 0 ? sep + relParts.join(sep) : '') + const fallback = activeEnv?.localStartPath ?? base + void (async () => { + const ok = await navigateLocalTo(newLocal) + if (!ok && normLocal(newLocal) !== normLocal(fallback)) { + void navigateLocalTo(fallback) + } + })() + } + + async function navigateLocal(entry: FileEntry): Promise { + if (entry.type !== 'directory') return + const isMirror = !!(activeEnv && mirrorCandidateKeys?.has(diffKey(entry))) + await navigateLocalTo(entry.path) + if (isMirror) { + // Use the actual remote folder name to handle case differences (e.g. Scripts vs scripts) + const remoteMatch = remoteEntries.find( + (e) => e.type === 'directory' && e.name.toLowerCase() === entry.name.toLowerCase() + ) + const name = remoteMatch?.name ?? entry.name + const remoteTarget = remotePath === '/' ? `/${name}` : `${remotePath}/${name}` + await navigateRemoteTo(remoteTarget) + } + } + + async function navigateRemote(entry: FileEntry): Promise { + if (!activeEnv || entry.type !== 'directory') return + const isMirror = mirrorCandidateKeys?.has(diffKey(entry)) ?? false + await navigateRemoteTo(entry.path) + if (isMirror) { + // Use the actual local folder name to handle case differences + const localMatch = localEntries.find( + (e) => e.type === 'directory' && e.name.toLowerCase() === entry.name.toLowerCase() + ) + const sep = localPath.includes('\\') ? '\\' : '/' + const name = localMatch?.name ?? entry.name + const localTarget = localPath ? `${localPath}${sep}${name}` : name + await navigateLocalTo(localTarget) + } + } + async function handleUpload(localPaths: string[], targetRemotePath: string): Promise { if (!activeEnv) return if (targetRemotePath === '/' || targetRemotePath === '') { @@ -270,6 +495,40 @@ export default function DualPaneBrowser(): React.JSX.Element { const localSelected = selected.pane === 'local' ? selected.paths : [] const remoteSelected = selected.pane === 'remote' ? selected.paths : [] + const isLoading = localLoading || remoteLoading + const mirrorActive = mirrorNav && pathsMatch + const stableMirrorActiveRef = useRef(mirrorActive) + if (!isLoading) stableMirrorActiveRef.current = mirrorActive + const stableMirrorActive = isLoading ? stableMirrorActiveRef.current : mirrorActive + + const localOnFiles = !!getDwRelativeTail(localPath) + const remoteOnFiles = !!getDwRelativeTail(toDisplayRemotePath(remotePath)) && ( + !!getDwRelativeTail(localPath) || !!activeEnv?.localStartPath + ) + + const [matchLocalExists, setMatchLocalExists] = useState(true) + useEffect(() => { + if (!remoteOnFiles) { setMatchLocalExists(true); return } + const filesBase = getLocalFilesBase() + if (!filesBase) { setMatchLocalExists(false); return } + const { base, sep } = filesBase + const relParts = remotePath.split('/').filter(Boolean) + const target = base + (relParts.length > 0 ? sep + relParts.join(sep) : '') + let cancelled = false + void window.dw.fs.list(target).then((r) => { if (!cancelled) setMatchLocalExists(r.ok) }) + return () => { cancelled = true } + }, [remotePath, localPath, activeEnv?.name, remoteOnFiles]) + + 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 + 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 = { padding: '7px 12px', borderTop: '1px solid var(--border)', @@ -280,12 +539,41 @@ export default function DualPaneBrowser(): React.JSX.Element { } return ( +
+ {activeEnv && ( + + )}
{/* 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) { + e.preventDefault() + void goBackLocal() + if (mirrorNav && pathsMatch) void goBackRemote() + } + if (e.button === 4 && localForwardStack.length > 0) { + e.preventDefault() + void goForwardLocal() + if (mirrorNav && pathsMatch) void goForwardRemote() + } }} style={{ display: 'flex', @@ -300,9 +588,29 @@ export default function DualPaneBrowser(): React.JSX.Element { { setLocalForwardStack([]); void loadLocal(localParentPath(localPath)) }} - onRefresh={() => void loadLocal(localPath)} - onNavigateTo={(p) => { setLocalForwardStack([]); void loadLocal(p) }} + mirrorActive={stableMirrorActive} + mirrorAccent="var(--accent)" + onNavigateUp={() => { + void navigateLocalTo(localParentPath(localPath)) + if (mirrorNav && pathsMatch) void navigateRemoteTo(parentPath(remotePath)) + }} + onRefresh={() => { + void loadLocal(localPath) + if (mirrorNav && pathsMatch && activeEnv) { + setRemoteLoading(true) + void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) + } + }} + onNavigateTo={(p) => { + const localTarget = (p === '' || p === '/') ? '' : p + void navigateLocalTo(localTarget) + if (mirrorNav) { + const relParts = getFilesRelativeParts(localTarget) + if (relParts !== null) { + void navigateRemoteTo(relParts.length > 0 ? '/' + relParts.join('/') : '/') + } + } + }} actions={ localSelected.length > 1 ? ( {localSelected.length} selected @@ -310,15 +618,27 @@ export default function DualPaneBrowser(): React.JSX.Element { } /> 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 (mirrorNav && pathsMatch) { + const names = new Set(paths.map((p) => (p.split(/[\\/]/).pop() ?? '').toLowerCase())) + setRemoteMirrorPaths(remoteEntries.filter((e) => e.type === 'directory' && names.has(e.name.toLowerCase())).map((e) => e.path)) + } else { + setRemoteMirrorPaths([]) + } + setLocalMirrorPaths([]) + }} pane="local" - selected={localSelected} + selected={localMirrorPaths.length > 0 ? [...localSelected, ...localMirrorPaths] : localSelected} + syncCandidates={mirrorCandidateKeys ?? undefined} /> {/* Conflict banner */} @@ -427,8 +747,16 @@ 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) { + e.preventDefault() + void goBackRemote() + if (mirrorNav && pathsMatch) void goBackLocal() + } + 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)' }} > @@ -485,15 +813,35 @@ export default function DualPaneBrowser(): React.JSX.Element { path={toDisplayRemotePath(remotePath)} label={envLabel(activeEnv)} sublabel={activeEnv.host} - onNavigateUp={() => { setRemoteForwardStack([]); void loadRemote(activeEnv.name, parentPath(remotePath)) }} + mirrorActive={stableMirrorActive} + upDisabled={remotePath === '/'} + onNavigateUp={() => { + void navigateRemoteTo(parentPath(remotePath)) + if (mirrorNav && pathsMatch) void navigateLocalTo(localParentPath(localPath)) + }} onRefresh={() => { setRemoteLoading(true) void loadRemote(activeEnv.name, remotePath).finally(() => setRemoteLoading(false)) + if (mirrorNav && pathsMatch) void loadLocal(localPath) }} onNavigateTo={(displayPath) => { const virtual = displayPath.replace(/^\/Files/, '') || '/' - setRemoteForwardStack([]) - void loadRemote(activeEnv.name, virtual) + void navigateRemoteTo(virtual) + if (mirrorNav) { + const filesBase = getLocalFilesBase() + if (filesBase) { + const { base, sep } = filesBase + const relParts = virtual.split('/').filter(Boolean) + void navigateLocalTo(base + (relParts.length > 0 ? sep + relParts.join(sep) : '')) + } else if (pathsMatch) { + const steps = pathSegmentCount(remotePath) - pathSegmentCount(virtual) + if (steps > 0) { + let localTarget = localPath + for (let i = 0; i < steps; i++) localTarget = localParentPath(localTarget) + void navigateLocalTo(localTarget) + } + } + } }} actions={ remoteSelected.length > 1 ? ( @@ -502,16 +850,28 @@ export default function DualPaneBrowser(): React.JSX.Element { } /> setContextMenu({ entry, x, y, pane: 'remote' })} 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 (mirrorNav && pathsMatch) { + const names = new Set(paths.map((p) => (p.split('/').pop() ?? '').toLowerCase())) + setLocalMirrorPaths(localEntries.filter((e) => e.type === 'directory' && names.has(e.name.toLowerCase())).map((e) => e.path)) + } else { + setLocalMirrorPaths([]) + } + setRemoteMirrorPaths([]) + }} pane="remote" - selected={remoteSelected} + selected={remoteMirrorPaths.length > 0 ? [...remoteSelected, ...remoteMirrorPaths] : remoteSelected} + syncCandidates={mirrorCandidateKeys ?? undefined} />
)}
+
{/* Context menu */} {contextMenu && (() => { diff --git a/dw-desktop/src/renderer/src/components/EditEnvModal.tsx b/dw-desktop/src/renderer/src/components/EditEnvModal.tsx index 91bba8d..b56e9ce 100644 --- a/dw-desktop/src/renderer/src/components/EditEnvModal.tsx +++ b/dw-desktop/src/renderer/src/components/EditEnvModal.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' @@ -37,10 +37,17 @@ export default function EditEnvModal({ env, onDone }: EditEnvModalProps): React. return cleanHost(raw).length > 0 } + 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) { - setLocalStartPath(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) { + setLocalStartPath(result.data.paths[0]) + } + } finally { + pickingRef.current = false } } diff --git a/dw-desktop/src/renderer/src/components/FileList.tsx b/dw-desktop/src/renderer/src/components/FileList.tsx index 8ea7a46..6eeacf1 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,23 @@ interface FileListProps { onDropOnPane?: (paths: string[]) => void dropTarget?: boolean pane?: 'local' | 'remote' + diffMap?: Map + highlightedStatuses?: DiffStatus[] + syncCandidates?: Set +} + +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 +104,10 @@ export default function FileList({ onDropIntoDir, onDropOnPane, dropTarget, - pane + pane, + diffMap, + highlightedStatuses, + syncCandidates }: FileListProps): React.JSX.Element { const isRemote = pane === 'remote' const [dragOverPath, setDragOverPath] = useState(null) @@ -189,6 +210,12 @@ 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 isSync = entry.type === 'directory' && !!syncCandidates?.has(diffKey(entry)) + 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' }} > @@ -272,7 +300,9 @@ export default function FileList({ flexShrink: 0 }} > - {entry.type === 'directory' ? '' : formatSize(entry.size)} + {isSync ? ( + + ) : entry.type === 'directory' ? '' : formatSize(entry.size)}
) diff --git a/dw-desktop/src/renderer/src/components/PaneHeader.tsx b/dw-desktop/src/renderer/src/components/PaneHeader.tsx index e2da708..591ecac 100644 --- a/dw-desktop/src/renderer/src/components/PaneHeader.tsx +++ b/dw-desktop/src/renderer/src/components/PaneHeader.tsx @@ -1,22 +1,61 @@ +import { useEffect, useRef, useState } from 'react' + interface PaneHeaderProps { label: string sublabel?: string path: string onNavigateUp: () => void + upDisabled?: boolean onRefresh: () => void onNavigateTo?: (path: string) => void + mirrorActive?: boolean + mirrorAccent?: string actions?: React.ReactNode } -function Breadcrumb({ path, onNavigateTo }: { path: string; onNavigateTo: (path: string) => void }): React.JSX.Element { - const isWindows = path.includes('\\') - const sep = isWindows ? '\\' : '/' - const parts = path.replace(/[/\\]$/, '').split(sep).filter(Boolean) +function Breadcrumb({ + path, + onNavigateTo, + mirrorActive, + mirrorAccent = 'var(--accent-cool)' +}: { + path: string + onNavigateTo: (path: string) => void + mirrorActive?: boolean + mirrorAccent?: string +}): React.JSX.Element { + const isWindows = path.includes('\\') || /^[A-Za-z]:/.test(path) + const parts = path + .replace(/[/\\]$/, '') + .split(/[\\/]/) + .filter(Boolean) + const filesIdx = mirrorActive ? parts.findIndex((p) => p.toLowerCase() === 'files') : -1 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 + const joined = parts.slice(0, i + 1).join('/') + 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 + '/' + return joined + } + return '/' + joined + } + + // Empty path = drives view. Show a static "Drives" label. + if (parts.length === 0) { + return ( + + Drives + + ) } return ( @@ -27,16 +66,57 @@ function Breadcrumb({ path, onNavigateTo }: { path: string; onNavigateTo: (path: fontSize: 11, fontFamily: 'var(--font-mono)', flex: 1, - minWidth: 0, + minWidth: 80, overflow: 'hidden', whiteSpace: 'nowrap' }} > + {/* On Windows, a leading "Drives" crumb returns to the drive list. */} + {isWindows && ( + + + + )} {parts.map((part, i) => { const isLast = i === parts.length - 1 + const isMirrored = filesIdx !== -1 && i >= filesIdx + const segColor = + isLast && isMirrored + ? `color-mix(in srgb, ${mirrorAccent} 35%, var(--text))` + : isLast + ? 'var(--text)' + : isMirrored + ? `color-mix(in srgb, ${mirrorAccent} 60%, var(--text-subtle))` + : 'var(--text-subtle)' + const sepColor = isMirrored + ? `color-mix(in srgb, ${mirrorAccent} 50%, transparent)` + : 'var(--text-subtle)' return ( - - {sep} + + / + )} diff --git a/dw-desktop/src/renderer/src/components/ThemeSwitcher.tsx b/dw-desktop/src/renderer/src/components/ThemeSwitcher.tsx index c9c3d6c..aaf4ff5 100644 --- a/dw-desktop/src/renderer/src/components/ThemeSwitcher.tsx +++ b/dw-desktop/src/renderer/src/components/ThemeSwitcher.tsx @@ -27,6 +27,7 @@ export default function ThemeSwitcher({ theme, onChange }: ThemeSwitcherProps): const active = theme === value return (