From 5cf4d549d889adb075d7b199cfb355f8a40b301b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Cer=C3=B3n?= Date: Wed, 19 Aug 2026 07:21:43 -0600 Subject: [PATCH 1/2] fix: scroll the save-as listing by the rows it actually draws filePanel reserved two rows under the listing for the filename field in save-as mode, but browse scrolled with the full, unreduced panel height. The two disagreed by exactly two rows, so holding the cursor down in a long listing pushed the selection below what was actually drawn, off the bottom of the panel. Factor the row count into one method and have both call sites use it, so they can no longer drift apart. Closes #5 --- internal/ui/app.go | 2 +- internal/ui/app_test.go | 27 +++++++++++++++++++++++++++ internal/ui/explorer.go | 5 ++--- internal/ui/overlay.go | 10 ++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 06a9294..36b2019 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -415,7 +415,7 @@ func (a App) keySaveAs(msg tea.KeyMsg) (App, tea.Cmd) { // browse handles the keys the listing shares between both dialogs. func (a *App) browse(msg tea.KeyMsg) { - window := a.panelHeight() + window := a.listRows() switch msg.String() { case "up": a.exp.move(-1, window) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index b252edc..9236cbf 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "os" "path/filepath" "testing" @@ -189,6 +190,32 @@ func TestSavingAnUnnamedDocumentOpensTheDialog(t *testing.T) { } } +// browse scrolled by the full panel height, but save-as only draws +// height-2 rows for the listing — the other two hold the filename field — +// so holding the cursor down could push it below what filePanel renders. +func TestSaveAsScrollStaysInsideWhatItDraws(t *testing.T) { + dir := t.TempDir() + for i := range 40 { + name := filepath.Join(dir, fmt.Sprintf("archivo%02d.md", i)) + if err := os.WriteFile(name, nil, 0o644); err != nil { + t.Fatal(err) + } + } + + a := testApp(t) + a.mode = ModeSaveAs + a.exp.refresh(dir, "") + + for range 50 { + a.browse(tea.KeyMsg{Type: tea.KeyDown}) + } + + rows := a.listRows() + if a.exp.cursor < a.exp.offset || a.exp.cursor >= a.exp.offset+rows { + t.Errorf("cursor %d outside the visible window [%d, %d)", a.exp.cursor, a.exp.offset, a.exp.offset+rows) + } +} + // Home, End and PageUp all index into the rows of the page, so a terminal too // narrow to hold any text still has to produce one. If that guard ever goes, // this test panics rather than fails. diff --git a/internal/ui/explorer.go b/internal/ui/explorer.go index ab721e9..faa8d36 100644 --- a/internal/ui/explorer.go +++ b/internal/ui/explorer.go @@ -166,12 +166,11 @@ func (e *explorer) rows(n, width int, focused bool) []string { // filePanel draws the open and save-as dialogs. They share the listing; save-as // adds the filename field underneath it. func (a App) filePanel() string { - width, height := a.panelWidth(), a.panelHeight() + width := a.panelWidth() - listRows := height + listRows := a.listRows() title := "open" if a.mode == ModeSaveAs { - listRows = max(height-2, 1) title = "save as" } diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index d7ccc4f..5d2e4de 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -32,6 +32,16 @@ func (a App) panel() string { func (a App) panelWidth() int { return min(max(a.w-8, 20), 64, a.w-2) } func (a App) panelHeight() int { return clamp(a.h-6, 6, 20) } +// listRows is how many rows of the listing filePanel actually draws, so +// browse scrolls by the same window instead of recomputing a different one — +// save-as reserves two rows underneath for the filename field. +func (a App) listRows() int { + if a.mode == ModeSaveAs { + return max(a.panelHeight()-2, 1) + } + return a.panelHeight() +} + func clamp(v, lo, hi int) int { return min(max(v, lo), hi) } // divider stands in for a body line that should be drawn as a rule joining From c2f5835a728b59e35b284d96d88db65164538d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mauricio=20Cer=C3=B3n?= Date: Wed, 19 Aug 2026 07:23:43 -0600 Subject: [PATCH 2/2] fix: absolutize the open document's path before the overwrite guard commitName compared an absolute path built from a.exp.dir (refresh already runs it through filepath.Abs) against a.ed.Path exactly as the user typed it, which stays relative for a document opened as `justwrite nota.md`. The two named the same file but never compared equal, so saving under the file's own name asked to overwrite the file already open. Closes #6 --- internal/ui/app.go | 7 ++++++- internal/ui/app_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 36b2019..8e7363c 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -478,7 +478,12 @@ func (a *App) commitName() tea.Cmd { } path := filepath.Join(a.exp.dir, name) - if _, err := os.Stat(path); err == nil && path != a.ed.Path { + edPath := a.ed.Path + if abs, err := filepath.Abs(edPath); err == nil { + edPath = abs + } + + if _, err := os.Stat(path); err == nil && path != edPath { a.name.Blur() a.confirm = confirmation{ message: "overwrite " + filepath.Base(path) + "?", diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 9236cbf..902cb54 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -216,6 +216,39 @@ func TestSaveAsScrollStaysInsideWhatItDraws(t *testing.T) { } } +// commitName compared an absolute path built from a.exp.dir against +// a.ed.Path exactly as the user typed it — relative if justwrite was +// launched as `justwrite nota.md`. Saving over the file already open under +// that name must not ask to overwrite itself. +func TestSaveAsOverTheOpenFileAsksNothing(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "nota.md"), []byte("hola"), 0o644); err != nil { + t.Fatal(err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer os.Chdir(wd) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + + a, err := NewApp("nota.md") + if err != nil { + t.Fatalf("NewApp: %v", err) + } + a.exp.refresh(a.startDir(), "") + a.name.SetValue("nota.md") + + a.commitName() + + if a.mode == ModeConfirm { + t.Error("asked to overwrite the file that is already open") + } +} + // Home, End and PageUp all index into the rows of the page, so a terminal too // narrow to hold any text still has to produce one. If that guard ever goes, // this test panics rather than fails.