Skip to content

Commit 542b491

Browse files
committed
Feat: Add pane resizing feature
- Resize panes between 25-75% - Double-click to reset to 50% - Store last pane size in app status
1 parent ab5bc2e commit 542b491

5 files changed

Lines changed: 170 additions & 36 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"file-explorer/FilePane.svelte": { "reason": "Tested in integration.test.ts, complex component" },
1212
"file-explorer/FullList.svelte": { "reason": "Logic tested in full-list-utils.ts, component mounting heavy" },
1313
"file-explorer/NetworkBrowser.svelte": { "reason": "Network component, needs Tauri integration" },
14+
"file-explorer/PaneResizer.svelte": { "reason": "Mouse drag UI component, difficult to unit test" },
1415
"file-explorer/NetworkLoginForm.svelte": { "reason": "Network component, needs Tauri integration" },
1516
"file-explorer/PermissionDeniedPane.svelte": { "reason": "Simple UI component" },
1617
"file-explorer/SelectionInfo.svelte": { "reason": "Logic tested in selection-info-utils.ts, DOM-dependent" },

apps/desktop/src/lib/app-status-store.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ export interface AppStatus {
2323
rightVolumeId: string
2424
leftSortBy: SortColumn
2525
rightSortBy: SortColumn
26+
/** Left pane width as percentage (25-75). Default: 50 */
27+
leftPaneWidthPercent: number
2628
}
2729

30+
const DEFAULT_LEFT_PANE_WIDTH_PERCENT = 50
31+
2832
const DEFAULT_STATUS: AppStatus = {
2933
leftPath: DEFAULT_PATH,
3034
rightPath: DEFAULT_PATH,
@@ -35,6 +39,7 @@ const DEFAULT_STATUS: AppStatus = {
3539
rightVolumeId: DEFAULT_VOLUME_ID,
3640
leftSortBy: DEFAULT_SORT_BY,
3741
rightSortBy: DEFAULT_SORT_BY,
42+
leftPaneWidthPercent: DEFAULT_LEFT_PANE_WIDTH_PERCENT,
3843
}
3944

4045
let storeInstance: Store | null = null
@@ -86,6 +91,13 @@ function parseSortColumn(raw: unknown): SortColumn {
8691
return DEFAULT_SORT_BY
8792
}
8893

94+
function parsePaneWidthPercent(raw: unknown): number {
95+
if (typeof raw === 'number' && raw >= 25 && raw <= 75) {
96+
return raw
97+
}
98+
return DEFAULT_LEFT_PANE_WIDTH_PERCENT
99+
}
100+
89101
export async function loadAppStatus(pathExists: (p: string) => Promise<boolean>): Promise<AppStatus> {
90102
try {
91103
const store = await getStore()
@@ -99,6 +111,7 @@ export async function loadAppStatus(pathExists: (p: string) => Promise<boolean>)
99111
const rightVolumeId = ((await store.get('rightVolumeId')) as string) || DEFAULT_VOLUME_ID
100112
const leftSortBy = parseSortColumn(await store.get('leftSortBy'))
101113
const rightSortBy = parseSortColumn(await store.get('rightSortBy'))
114+
const leftPaneWidthPercent = parsePaneWidthPercent(await store.get('leftPaneWidthPercent'))
102115

103116
// Resolve paths with fallback - skip for virtual 'network' volume
104117
const resolvedLeftPath =
@@ -116,6 +129,7 @@ export async function loadAppStatus(pathExists: (p: string) => Promise<boolean>)
116129
rightVolumeId,
117130
leftSortBy,
118131
rightSortBy,
132+
leftPaneWidthPercent,
119133
}
120134
} catch {
121135
// If store fails, return defaults
@@ -153,6 +167,9 @@ export async function saveAppStatus(status: Partial<AppStatus>): Promise<void> {
153167
if (status.rightSortBy !== undefined) {
154168
await store.set('rightSortBy', status.rightSortBy)
155169
}
170+
if (status.leftPaneWidthPercent !== undefined) {
171+
await store.set('leftPaneWidthPercent', status.leftPaneWidthPercent)
172+
}
156173
await store.save()
157174
} catch {
158175
// Silently fail - persistence is nice-to-have

apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { onMount, onDestroy } from 'svelte'
33
import FilePane from './FilePane.svelte'
4+
import PaneResizer from './PaneResizer.svelte'
45
import LoadingIcon from '../LoadingIcon.svelte'
56
import {
67
loadAppStatus,
@@ -52,6 +53,7 @@
5253
let rightVolumeId = $state(DEFAULT_VOLUME_ID)
5354
let volumes = $state<VolumeInfo[]>([])
5455
let initialized = $state(false)
56+
let leftPaneWidthPercent = $state(50)
5557
5658
// Sorting state - per-pane
5759
let leftSortBy = $state<SortColumn>(DEFAULT_SORT_BY)
@@ -511,6 +513,7 @@
511513
showHiddenFiles = settings.showHiddenFiles
512514
leftViewMode = status.leftViewMode
513515
rightViewMode = status.rightViewMode
516+
leftPaneWidthPercent = status.leftPaneWidthPercent
514517
515518
// Load sort state
516519
leftSortBy = status.leftSortBy
@@ -726,6 +729,19 @@
726729
cleanupNetworkDiscovery()
727730
})
728731
732+
function handlePaneResize(widthPercent: number) {
733+
leftPaneWidthPercent = widthPercent
734+
}
735+
736+
function handlePaneResizeEnd() {
737+
void saveAppStatus({ leftPaneWidthPercent })
738+
}
739+
740+
function handlePaneResizeReset() {
741+
leftPaneWidthPercent = 50
742+
void saveAppStatus({ leftPaneWidthPercent: 50 })
743+
}
744+
729745
// Focus the container after initialization so keyboard events work
730746
$effect(() => {
731747
if (initialized) {
@@ -950,42 +966,47 @@
950966
aria-label="File explorer"
951967
>
952968
{#if initialized}
953-
<FilePane
954-
bind:this={leftPaneRef}
955-
paneId="left"
956-
initialPath={leftPath}
957-
volumeId={leftVolumeId}
958-
volumePath={leftVolumePath}
959-
isFocused={focusedPane === 'left'}
960-
{showHiddenFiles}
961-
viewMode={leftViewMode}
962-
sortBy={leftSortBy}
963-
sortOrder={leftSortOrder}
964-
onPathChange={handleLeftPathChange}
965-
onVolumeChange={handleLeftVolumeChange}
966-
onRequestFocus={handleLeftFocus}
967-
onSortChange={handleLeftSortChange}
968-
onNetworkHostChange={handleLeftNetworkHostChange}
969-
onCancelLoading={handleLeftCancelLoading}
970-
/>
971-
<FilePane
972-
bind:this={rightPaneRef}
973-
paneId="right"
974-
initialPath={rightPath}
975-
volumeId={rightVolumeId}
976-
volumePath={rightVolumePath}
977-
isFocused={focusedPane === 'right'}
978-
{showHiddenFiles}
979-
viewMode={rightViewMode}
980-
sortBy={rightSortBy}
981-
sortOrder={rightSortOrder}
982-
onPathChange={handleRightPathChange}
983-
onVolumeChange={handleRightVolumeChange}
984-
onRequestFocus={handleRightFocus}
985-
onSortChange={handleRightSortChange}
986-
onNetworkHostChange={handleRightNetworkHostChange}
987-
onCancelLoading={handleRightCancelLoading}
988-
/>
969+
<div class="pane-wrapper" style="width: {leftPaneWidthPercent}%">
970+
<FilePane
971+
bind:this={leftPaneRef}
972+
paneId="left"
973+
initialPath={leftPath}
974+
volumeId={leftVolumeId}
975+
volumePath={leftVolumePath}
976+
isFocused={focusedPane === 'left'}
977+
{showHiddenFiles}
978+
viewMode={leftViewMode}
979+
sortBy={leftSortBy}
980+
sortOrder={leftSortOrder}
981+
onPathChange={handleLeftPathChange}
982+
onVolumeChange={handleLeftVolumeChange}
983+
onRequestFocus={handleLeftFocus}
984+
onSortChange={handleLeftSortChange}
985+
onNetworkHostChange={handleLeftNetworkHostChange}
986+
onCancelLoading={handleLeftCancelLoading}
987+
/>
988+
</div>
989+
<PaneResizer onResize={handlePaneResize} onResizeEnd={handlePaneResizeEnd} onReset={handlePaneResizeReset} />
990+
<div class="pane-wrapper" style="width: {100 - leftPaneWidthPercent}%">
991+
<FilePane
992+
bind:this={rightPaneRef}
993+
paneId="right"
994+
initialPath={rightPath}
995+
volumeId={rightVolumeId}
996+
volumePath={rightVolumePath}
997+
isFocused={focusedPane === 'right'}
998+
{showHiddenFiles}
999+
viewMode={rightViewMode}
1000+
sortBy={rightSortBy}
1001+
sortOrder={rightSortOrder}
1002+
onPathChange={handleRightPathChange}
1003+
onVolumeChange={handleRightVolumeChange}
1004+
onRequestFocus={handleRightFocus}
1005+
onSortChange={handleRightSortChange}
1006+
onNetworkHostChange={handleRightNetworkHostChange}
1007+
onCancelLoading={handleRightCancelLoading}
1008+
/>
1009+
</div>
9891010
{:else}
9901011
<LoadingIcon />
9911012
{/if}
@@ -999,4 +1020,11 @@
9991020
gap: 0;
10001021
outline: none;
10011022
}
1023+
1024+
.pane-wrapper {
1025+
display: flex;
1026+
flex-direction: column;
1027+
height: 100%;
1028+
min-width: 0;
1029+
}
10021030
</style>

apps/desktop/src/lib/file-explorer/DualPaneExplorer.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ vi.mock('$lib/app-status-store', () => ({
1515
rightSortBy: 'name',
1616
leftViewMode: 'brief',
1717
rightViewMode: 'brief',
18+
leftPaneWidthPercent: 50,
1819
}),
1920
saveAppStatus: vi.fn().mockResolvedValue(undefined),
2021
getLastUsedPathForVolume: vi.fn().mockResolvedValue(undefined),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<script lang="ts">
2+
interface Props {
3+
onResize: (widthPercent: number) => void
4+
onResizeEnd: () => void
5+
onReset: () => void
6+
}
7+
8+
const { onResize, onResizeEnd, onReset }: Props = $props()
9+
10+
let isDragging = $state(false)
11+
12+
function handleMouseDown(event: MouseEvent) {
13+
event.preventDefault()
14+
isDragging = true
15+
16+
// Capture the container reference at drag start (not during mousemove)
17+
const container = (event.target as HTMLElement).closest('.dual-pane-explorer')
18+
if (!container) return
19+
20+
const handleMouseMove = (moveEvent: MouseEvent) => {
21+
const rect = container.getBoundingClientRect()
22+
const mouseX = moveEvent.clientX - rect.left
23+
const widthPercent = (mouseX / rect.width) * 100
24+
25+
// Clamp to 25-75%
26+
const clampedPercent = Math.max(25, Math.min(75, widthPercent))
27+
onResize(clampedPercent)
28+
}
29+
30+
const handleMouseUp = () => {
31+
isDragging = false
32+
onResizeEnd()
33+
document.removeEventListener('mousemove', handleMouseMove)
34+
document.removeEventListener('mouseup', handleMouseUp)
35+
document.body.style.cursor = ''
36+
}
37+
38+
document.addEventListener('mousemove', handleMouseMove)
39+
document.addEventListener('mouseup', handleMouseUp)
40+
document.body.style.cursor = 'col-resize'
41+
}
42+
</script>
43+
44+
<div
45+
class="pane-resizer"
46+
class:dragging={isDragging}
47+
onmousedown={handleMouseDown}
48+
ondblclick={onReset}
49+
role="separator"
50+
aria-orientation="vertical"
51+
aria-label="Resize panes"
52+
>
53+
<div class="handle"></div>
54+
</div>
55+
56+
<style>
57+
.pane-resizer {
58+
width: 5px;
59+
cursor: col-resize;
60+
display: flex;
61+
align-items: center;
62+
justify-content: center;
63+
background: var(--color-border-primary);
64+
flex-shrink: 0;
65+
transition: background-color 0.15s;
66+
}
67+
68+
.pane-resizer:hover,
69+
.pane-resizer.dragging {
70+
background: var(--color-accent);
71+
}
72+
73+
.handle {
74+
width: 3px;
75+
height: 24px;
76+
border-radius: 2px;
77+
background: var(--color-text-muted);
78+
opacity: 0;
79+
transition: opacity 0.15s;
80+
}
81+
82+
.pane-resizer:hover .handle,
83+
.pane-resizer.dragging .handle {
84+
opacity: 1;
85+
background: var(--color-cursor-focused-fg);
86+
}
87+
</style>

0 commit comments

Comments
 (0)