Skip to content
78 changes: 76 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ type App struct {
// scp / ssh diagnostics that naturally wrap.
confirmInfo bool
confirmMessageLines []string
confirmInfoScroll int

// Save/Discard/Cancel modal — used when closing a dirty tab or
// quitting with unsaved changes. dirtyHover indexes the button row:
Expand Down Expand Up @@ -539,11 +540,24 @@ func (a *App) refreshGitStatus() {
a.tree.DirtyFiles = nil
a.tree.DirtyFolders = nil
a.gitBranch = ""
a.refreshGitLineChanges()
return
}
a.tree.DirtyFiles = st.DirtyFiles
a.tree.DirtyFolders = dirtyFolderSet(st.DirtyFiles, a.rootDir)
dirtyFiles := rebaseGitPaths(st.DirtyFiles, a.tree.Root.Path)
a.tree.DirtyFiles = dirtyFiles
a.tree.DirtyFolders = dirtyFolderSet(dirtyFiles, a.tree.Root.Path)
a.gitBranch = st.Branch
a.refreshGitLineChanges()
}

// refreshGitLineChanges refreshes gutter markers for every open text tab.
func (a *App) refreshGitLineChanges() {
for _, tab := range a.tabs {
if tab == nil || tab.Path == "" || tab.IsImage() {
continue
}
tab.GitLines = loadGitLineChanges(a.rootDir, tab.Path)
}
}

// startTreeRefresh launches a goroutine that posts a treeRefreshEvent every
Expand Down Expand Up @@ -1257,6 +1271,9 @@ func (a *App) sidebarClick(x, y int) {
// mirrors it onto the file tree so the matching row renders with the
// "active" highlight. All writes to a.activeFolder go through here.
func (a *App) setActiveFolder(path string) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
a.activeFolder = path
if a.tree != nil {
a.tree.ActiveFolder = path
Expand All @@ -1279,11 +1296,25 @@ func (a *App) tabBarClick(x, _ int) {
return
}
a.activeTab = r.Index
a.syncActiveTreeFile()
return
}
}
}

// syncActiveTreeFile mirrors the active tab path into the file tree.
func (a *App) syncActiveTreeFile() {
if a.tree == nil {
return
}
tab := a.activeTabPtr()
if tab == nil || tab.Path == "" {
a.tree.ActiveFile = ""
return
}
a.tree.ActiveFile = tab.Path
}

// editorPress handles the initial mouse press inside the editor — placing
// the caret, optionally selecting a word on double-click. Image tabs
// have no caret, so the press is dropped.
Expand All @@ -1293,6 +1324,9 @@ func (a *App) editorPress(x, y int) {
return
}
ex, ey, ew, eh := a.editorRect()
if a.openGitHunkAt(tab, x-ex, y-ey) {
return
}
pos, ok := tab.HitTest(x-ex, y-ey, ew, eh)
if !ok {
return
Expand All @@ -1308,6 +1342,24 @@ func (a *App) editorPress(x, y int) {
tab.MoveCursorTo(pos, false)
}

// openGitHunkAt opens a diff preview when the user clicks a gutter marker.
func (a *App) openGitHunkAt(tab *editor.Tab, localX, localY int) bool {
if localX != 0 || localY < 0 {
return false
}
line := tab.ScrollY + localY
if tab.GitLines[line] == editor.GitLineNone {
return false
}
lines := loadGitHunkPreview(a.rootDir, tab.Path, line)
if len(lines) == 0 {
a.openInfo("Git change", []string{"No git diff found for this line."})
return true
}
a.openInfo("Git change · "+filepath.Base(tab.Path), lines)
return true
}

// editorDrag extends the selection during a click-drag inside the editor.
// (x, y) is clamped to the editor rect so dragging into another pane still
// extends the selection sensibly. When the mouse passes above or below the
Expand Down Expand Up @@ -1498,10 +1550,30 @@ func (a *App) OpenFile(path string) { a.openFile(path) }
// Whatever the path resolves to, its parent becomes the active folder so
// the next New File from the main menu lands next to it.
func (a *App) openFile(path string) {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
a.setActiveFolder(filepath.Dir(path))
if a.tree != nil {
a.tree.ActiveFile = path
// Reveal the file's location in the sidebar: expand every ancestor
// directory and scroll the row into view. Without this, opening a
// file via the finder (Esc-p) or the command line leaves the tree
// collapsed at the top, so the active-file highlight is set on a
// row nobody can see. listH mirrors Render's own list-area height
// (sidebarH - 2) so the "already visible" guard inside Reveal uses
// the same viewport the next paint will.
_, _, _, sh := a.sidebarRect()
listH := sh - 2
if listH < 0 {
listH = 0
}
a.tree.Reveal(path, listH)
}
for i, t := range a.tabs {
if t.Path == path {
a.activeTab = i
t.GitLines = loadGitLineChanges(a.rootDir, t.Path)
return
}
}
Expand All @@ -1512,6 +1584,7 @@ func (a *App) openFile(path string) {
}
a.tabs = append(a.tabs, t)
a.activeTab = len(a.tabs) - 1
t.GitLines = loadGitLineChanges(a.rootDir, t.Path)
a.flash(fmt.Sprintf("Opened %s", filepath.Base(path)))
}

Expand Down Expand Up @@ -1623,6 +1696,7 @@ func (a *App) closeTab(idx int) {
if a.activeTab < 0 {
a.activeTab = 0
}
a.syncActiveTreeFile()
}

// copySelection puts the active tab's selection on the system clipboard
Expand Down
32 changes: 32 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,38 @@ func TestEditorPress_PlacesCaret(t *testing.T) {
}
}

// TestOpenGitHunkAt_OpensInfoOnMarker proves gutter markers are clickable.
func TestOpenGitHunkAt_OpensInfoOnMarker(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "p.txt")
if err := os.WriteFile(target, []byte("hello\n"), 0644); err != nil {
t.Fatalf("seed: %v", err)
}
a := newTestApp(t, dir)
a.openFile(target)
tab := a.activeTabPtr()
tab.GitLines = map[int]editor.GitLineChange{0: editor.GitLineModified}

if !a.openGitHunkAt(tab, 0, 0) {
t.Fatal("expected gutter marker click to be handled")
}
if !a.confirmOpen || !a.confirmInfo {
t.Fatal("expected git hunk click to open info modal")
}
}

// TestOpenGitHunkAt_IgnoresCleanGutter keeps normal cursor placement intact.
func TestOpenGitHunkAt_IgnoresCleanGutter(t *testing.T) {
a := newTestApp(t, t.TempDir())
tab, err := editor.NewTab("")
if err != nil {
t.Fatalf("NewTab: %v", err)
}
if a.openGitHunkAt(tab, 0, 0) {
t.Fatal("clean gutter should not be handled as a git preview")
}
}

// TestEditorPress_DoubleClickSelectsWord triggers the word-select path.
func TestEditorPress_DoubleClickSelectsWord(t *testing.T) {
dir := t.TempDir()
Expand Down
77 changes: 77 additions & 0 deletions internal/app/finder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"testing"
"time"

"github.com/cloudmanic/spice-edit/internal/filetree"
"github.com/cloudmanic/spice-edit/internal/finder"
"github.com/gdamore/tcell/v2"
)
Expand Down Expand Up @@ -243,6 +244,82 @@ func TestLeader_PFiresFinder(t *testing.T) {
}
}

// TestFinder_EnterRevealsFileInTree is the headline fix for the sidebar-sync
// bug: opening a file via the finder (Esc-p → type → Enter) used to set the
// active-file highlight on a row nobody could see, because the tree stayed
// collapsed at the top. After the fix, openFile calls tree.Reveal, which
// expands every ancestor and scrolls the row into view. This test opens a
// nested file under internal/finder/ through the finder keystroke loop and
// asserts both that the ancestor dir is expanded and that the file's row is
// inside the tree's viewport (via the public HitTest contract).
func TestFinder_EnterRevealsFileInTree(t *testing.T) {
a, dir := withFinder(t)
a.openFinder()
for _, r := range "score" {
a.handleFinderKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
}
if len(a.finderResults) == 0 {
t.Fatal("expected score results")
}
rel := a.finderResults[0].Path
want := filepath.Join(dir, rel)

a.handleFinderKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))

// The finder returns paths like "internal/finder/score.go" — so the
// "internal" ancestor must now be expanded, and the file's row must be
// inside the tree's viewport.
internal := treeChildByName(a.tree.Root, "internal")
if internal == nil {
t.Fatal("internal/ ancestor missing from tree")
}
if !internal.Expanded {
t.Fatal("internal/ should be expanded after opening via finder")
}
if a.tree.ActiveFile != want {
t.Fatalf("ActiveFile: got %q, want %q", a.tree.ActiveFile, want)
}
// Re-render so the tree's visible-rows cache reflects the post-reveal
// flat list, then walk the list rows via HitTest and confirm the file
// is on screen. Using the public HitTest contract keeps the test honest
// about what a user would actually see.
sx, sy, sw, sh := a.sidebarRect()
a.tree.Render(a.screen, a.theme, sx, sy, sw, sh)
listH := sh - 2
if listH < 0 {
listH = 0
}
found := false
for row := 0; row < listH; row++ {
n, ok := a.tree.HitTest(0, row+2) // list rows start at localY 2
if !ok || n == nil {
continue
}
if n.Path == want {
found = true
break
}
}
if !found {
t.Fatalf("opened file %q not visible in tree after reveal (ScrollY=%d)", rel, a.tree.ScrollY)
}
}

// treeChildByName returns the direct child of n named name, or nil. A tiny
// local helper so the finder-reveal test can inspect ancestor expansion
// without reaching into the package's private fields.
func treeChildByName(n *filetree.Node, name string) *filetree.Node {
if n == nil {
return nil
}
for _, c := range n.Children {
if c.Name == name {
return c
}
}
return nil
}

// endsWith is a tiny string suffix check pulled in so the result-
// path assertions in this file read as the rule they're enforcing.
func endsWith(s, suffix string) bool {
Expand Down
Loading