detached 9246f54

fix(resize): make smart resize grow active pane

commit
9246f54303ae734e3c5412626b7d51436240dbfa
author
brice · · 2025-12-26 07:10 UTC
parent
fad762b
signature
unsigned
4 files · +93 -21
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
89 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
910 - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
1011 - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
8 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.9 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
9 - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.10 - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
10 - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.11 - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
M README.md +10 -0
@@ -80,6 +80,16 @@ Early development with regular releases. Core features work well for daily use b
8080 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
8181 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
8282
83+#### Resize Mode
84+| Shortcut | Action | Notes |
85+|----------|--------|-------|
86+| **Ctrl+N** | Enter Resize Mode | Modal mode for pane resizing |
87+| **←/↓/↑/→** / **h/j/k/l** (in resize mode) | Move Divider | Resizes the nearest split by moving the divider |
88+| **H/J/K/L** (in resize mode) | Move Divider (Inverse) | Inverts the direction |
89+| **+ / -** (in resize mode) | Smart Resize | Grow/shrink the active pane (best-effort) |
90+| **Enter** (in resize mode) | Confirm | Exit resize mode |
91+| **Escape** (in resize mode) | Cancel | Exit resize mode |
92+
8393 #### Session Management
8494 | Shortcut | Action | Notes |
8595 |----------|--------|-------|
@@ -80,6 +80,16 @@ Early development with regular releases. Core features work well for daily use b
80 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |80 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
81 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |81 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
82 82
83+#### Resize Mode
84+| Shortcut | Action | Notes |
85+|----------|--------|-------|
86+| **Ctrl+N** | Enter Resize Mode | Modal mode for pane resizing |
87+| **←/↓/↑/→** / **h/j/k/l** (in resize mode) | Move Divider | Resizes the nearest split by moving the divider |
88+| **H/J/K/L** (in resize mode) | Move Divider (Inverse) | Inverts the direction |
89+| **+ / -** (in resize mode) | Smart Resize | Grow/shrink the active pane (best-effort) |
90+| **Enter** (in resize mode) | Confirm | Exit resize mode |
91+| **Escape** (in resize mode) | Cancel | Exit resize mode |
92+
83 #### Session Management93 #### Session Management
84 | Shortcut | Action | Notes |94 | Shortcut | Action | Notes |
85 |----------|--------|-------|95 |----------|--------|-------|
M docs/CONFIG.md +34 -0
@@ -243,6 +243,38 @@ cancel = ["escape"]
243243
244244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
245245
246+### Resize Mode
247+
248+| Key | Type | Default | Description |
249+|-----|------|---------|-------------|
250+| `workspace.resize_mode.activation_shortcut` | string | `"ctrl+n"` | Resize mode activation key |
251+| `workspace.resize_mode.timeout_ms` | int | `3000` | Resize mode timeout (ms) |
252+| `workspace.resize_mode.step_percent` | float | `5.0` | Split ratio step per keystroke (percent) |
253+| `workspace.resize_mode.min_pane_percent` | float | `10.0` | Minimum pane size (percent) |
254+| `workspace.resize_mode.actions` | map | See below | Action→keys mappings |
255+
256+**Default resize mode actions:**
257+```toml
258+[workspace.resize_mode.actions]
259+resize-increase-left = ["arrowleft", "h"]
260+resize-increase-down = ["arrowdown", "j"]
261+resize-increase-up = ["arrowup", "k"]
262+resize-increase-right = ["arrowright", "l"]
263+resize-decrease-left = ["H"]
264+resize-decrease-down = ["J"]
265+resize-decrease-up = ["K"]
266+resize-decrease-right = ["L"]
267+resize-increase = ["+", "="]
268+resize-decrease = ["-"]
269+confirm = ["enter"]
270+cancel = ["escape"]
271+```
272+
273+Notes:
274+- Directional actions (`resize-increase-*/resize-decrease-*`) move the split divider.
275+- Smart actions (`resize-increase` / `resize-decrease`) grow/shrink the active pane (best-effort) by picking a direction automatically.
276+- Timeout is refreshed on each resize keypress so you can keep adjusting without re-entering the mode.
277+
246278 ### Global Shortcuts
247279
248280 | Key | Type | Default | Description |
@@ -277,6 +309,8 @@ cancel = ["escape"]
277309 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
278310 | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
279311 | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
312+| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
313+| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
280314 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
281315
282316 ## Session
@@ -243,6 +243,38 @@ cancel = ["escape"]
243 243
244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
245 245
246+### Resize Mode
247+
248+| Key | Type | Default | Description |
249+|-----|------|---------|-------------|
250+| `workspace.resize_mode.activation_shortcut` | string | `"ctrl+n"` | Resize mode activation key |
251+| `workspace.resize_mode.timeout_ms` | int | `3000` | Resize mode timeout (ms) |
252+| `workspace.resize_mode.step_percent` | float | `5.0` | Split ratio step per keystroke (percent) |
253+| `workspace.resize_mode.min_pane_percent` | float | `10.0` | Minimum pane size (percent) |
254+| `workspace.resize_mode.actions` | map | See below | Action→keys mappings |
255+
256+**Default resize mode actions:**
257+```toml
258+[workspace.resize_mode.actions]
259+resize-increase-left = ["arrowleft", "h"]
260+resize-increase-down = ["arrowdown", "j"]
261+resize-increase-up = ["arrowup", "k"]
262+resize-increase-right = ["arrowright", "l"]
263+resize-decrease-left = ["H"]
264+resize-decrease-down = ["J"]
265+resize-decrease-up = ["K"]
266+resize-decrease-right = ["L"]
267+resize-increase = ["+", "="]
268+resize-decrease = ["-"]
269+confirm = ["enter"]
270+cancel = ["escape"]
271+```
272+
273+Notes:
274+- Directional actions (`resize-increase-*/resize-decrease-*`) move the split divider.
275+- Smart actions (`resize-increase` / `resize-decrease`) grow/shrink the active pane (best-effort) by picking a direction automatically.
276+- Timeout is refreshed on each resize keypress so you can keep adjusting without re-entering the mode.
277+
246 ### Global Shortcuts278 ### Global Shortcuts
247 279
248 | Key | Type | Default | Description |280 | Key | Type | Default | Description |
@@ -277,6 +309,8 @@ cancel = ["escape"]
277 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |309 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
278 | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |310 | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
279 | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |311 | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
312+| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
313+| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
280 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |314 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
281 315
282 ## Session316 ## Session
M internal/application/usecase/manage_panes.go +48 -21
@@ -221,19 +221,9 @@ func (uc *ManagePanesUseCase) Resize(
221221 actualDir := dir
222222 switch dir {
223223 case ResizeIncrease:
224- actualDir = findFirstAvailableDirection(target, []ResizeDirection{
225- ResizeIncreaseRight,
226- ResizeIncreaseDown,
227- ResizeIncreaseLeft,
228- ResizeIncreaseUp,
229- })
224+ actualDir = findSmartResizeDirection(target, true)
230225 case ResizeDecrease:
231- actualDir = findFirstAvailableDirection(target, []ResizeDirection{
232- ResizeDecreaseLeft,
233- ResizeDecreaseUp,
234- ResizeDecreaseRight,
235- ResizeDecreaseDown,
236- })
226+ actualDir = findSmartResizeDirection(target, false)
237227 }
238228 if actualDir == "" {
239229 return ErrNothingToResize
@@ -270,18 +260,55 @@ func (uc *ManagePanesUseCase) Resize(
270260 return nil
271261 }
272262
273-func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection {
274- for _, d := range dirs {
275- axis, ok := axisForResizeDirection(d)
276- if !ok {
277- continue
263+func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
264+ splitNode, axis, isStartChild := findNearestSplitForResize(target)
265+ if splitNode == nil {
266+ return ""
267+ }
268+
269+ // Smart resize means "grow/shrink the active pane".
270+ // SplitRatio is the proportion allocated to the first child (left/top).
271+ // - If active is first child: grow by increasing ratio, shrink by decreasing.
272+ // - If active is second child: grow by decreasing ratio, shrink by increasing.
273+ growMeansIncreaseRatio := isStartChild
274+ if !growActive {
275+ growMeansIncreaseRatio = !growMeansIncreaseRatio
276+ }
277+
278+ switch axis {
279+ case resizeAxisHorizontal:
280+ if growMeansIncreaseRatio {
281+ return ResizeIncreaseRight
282+ }
283+ return ResizeIncreaseLeft
284+ case resizeAxisVertical:
285+ if growMeansIncreaseRatio {
286+ return ResizeIncreaseDown
278287 }
279- splitNode := findNearestSplitForAxis(target, axis)
280- if splitNode != nil {
281- return d
288+ return ResizeIncreaseUp
289+ default:
290+ return ""
291+ }
292+}
293+
294+// findNearestSplitForResize returns the nearest split ancestor for the active pane.
295+// It prefers horizontal splits over vertical when both are available at the same depth.
296+func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
297+ current := node
298+ for current != nil && current.Parent != nil {
299+ parent := current.Parent
300+ if parent.IsSplit() {
301+ isStartChild = parent.Left() == current
302+ switch parent.SplitDir {
303+ case entity.SplitHorizontal:
304+ return parent, resizeAxisHorizontal, isStartChild
305+ case entity.SplitVertical:
306+ return parent, resizeAxisVertical, isStartChild
307+ }
282308 }
309+ current = parent
283310 }
284- return ""
311+ return nil, resizeAxisNone, false
285312 }
286313
287314 type resizeAxis int
@@ -221,19 +221,9 @@ func (uc *ManagePanesUseCase) Resize(
221 actualDir := dir221 actualDir := dir
222 switch dir {222 switch dir {
223 case ResizeIncrease:223 case ResizeIncrease:
224- actualDir = findFirstAvailableDirection(target, []ResizeDirection{224+ actualDir = findSmartResizeDirection(target, true)
225- ResizeIncreaseRight,
226- ResizeIncreaseDown,
227- ResizeIncreaseLeft,
228- ResizeIncreaseUp,
229- })
230 case ResizeDecrease:225 case ResizeDecrease:
231- actualDir = findFirstAvailableDirection(target, []ResizeDirection{226+ actualDir = findSmartResizeDirection(target, false)
232- ResizeDecreaseLeft,
233- ResizeDecreaseUp,
234- ResizeDecreaseRight,
235- ResizeDecreaseDown,
236- })
237 }227 }
238 if actualDir == "" {228 if actualDir == "" {
239 return ErrNothingToResize229 return ErrNothingToResize
@@ -270,18 +260,55 @@ func (uc *ManagePanesUseCase) Resize(
270 return nil260 return nil
271 }261 }
272 262
273-func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection {263+func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
274- for _, d := range dirs {264+ splitNode, axis, isStartChild := findNearestSplitForResize(target)
275- axis, ok := axisForResizeDirection(d)265+ if splitNode == nil {
276- if !ok {266+ return ""
277- continue267+ }
268+
269+ // Smart resize means "grow/shrink the active pane".
270+ // SplitRatio is the proportion allocated to the first child (left/top).
271+ // - If active is first child: grow by increasing ratio, shrink by decreasing.
272+ // - If active is second child: grow by decreasing ratio, shrink by increasing.
273+ growMeansIncreaseRatio := isStartChild
274+ if !growActive {
275+ growMeansIncreaseRatio = !growMeansIncreaseRatio
276+ }
277+
278+ switch axis {
279+ case resizeAxisHorizontal:
280+ if growMeansIncreaseRatio {
281+ return ResizeIncreaseRight
282+ }
283+ return ResizeIncreaseLeft
284+ case resizeAxisVertical:
285+ if growMeansIncreaseRatio {
286+ return ResizeIncreaseDown
278 }287 }
279- splitNode := findNearestSplitForAxis(target, axis)288+ return ResizeIncreaseUp
280- if splitNode != nil {289+ default:
281- return d290+ return ""
291+ }
292+}
293+
294+// findNearestSplitForResize returns the nearest split ancestor for the active pane.
295+// It prefers horizontal splits over vertical when both are available at the same depth.
296+func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
297+ current := node
298+ for current != nil && current.Parent != nil {
299+ parent := current.Parent
300+ if parent.IsSplit() {
301+ isStartChild = parent.Left() == current
302+ switch parent.SplitDir {
303+ case entity.SplitHorizontal:
304+ return parent, resizeAxisHorizontal, isStartChild
305+ case entity.SplitVertical:
306+ return parent, resizeAxisVertical, isStartChild
307+ }
282 }308 }
309+ current = parent
283 }310 }
284- return ""311+ return nil, resizeAxisNone, false
285 }312 }
286 313
287 type resizeAxis int314 type resizeAxis int

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close