From 1607693dbc5fffe7ef4636afd72f5cf2217d0883 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 14 Nov 2026 15:26:49 -0800 Subject: [PATCH 1/4] feat(editor): add core split groups --- README.md | 2 + config/keybindings.json | 4 + config/settings.json | 3 +- docs-web/src/content/docs/guides/editor.md | 14 +- .../src/content/docs/reference/keybindings.md | 8 +- .../src/content/docs/reference/settings.md | 4 +- internal/app/app.go | 11 +- internal/app/app_lsp.go | 10 +- internal/app/callbacks.go | 49 +- internal/app/changes_async.go | 25 +- internal/app/commands_editor.go | 31 +- internal/app/commands_plugin.go | 79 ++- internal/app/commands_settings.go | 108 ++-- internal/app/commands_view.go | 64 +-- internal/app/commit_detail_context.go | 4 +- internal/app/debug_dump.go | 20 +- internal/app/editor_group_menu.go | 20 + internal/app/editor_split.go | 227 ++++++++ internal/app/editor_split_async_test.go | 99 ++++ internal/app/editor_split_lifecycle_test.go | 71 +++ internal/app/eventloop.go | 29 +- internal/app/file_handlers.go | 8 +- internal/app/fileops.go | 4 +- internal/app/gitgutter.go | 20 +- internal/app/grammars.go | 4 +- internal/app/images.go | 4 +- internal/app/large_files.go | 2 +- internal/app/lsp_positions.go | 8 +- internal/app/lsp_pull.go | 8 +- internal/app/lsp_restart.go | 8 +- internal/app/pr.go | 19 + internal/app/save_as_lsp_test.go | 138 +++-- internal/app/settings_view.go | 3 + internal/app/theme.go | 8 + internal/app/theme_border_test.go | 24 + internal/app/watch.go | 23 +- internal/app/widgets.go | 87 +-- internal/config/keybindings.go | 4 + internal/config/settings.go | 18 +- internal/config/theme.go | 1 + internal/i18n/en.json | 12 + internal/term/border.go | 7 + internal/ui/content_split.go | 97 +++- internal/ui/content_split_resize_test.go | 31 +- internal/ui/editor_group.go | 47 +- internal/ui/editor_group_shared.go | 20 + internal/ui/editor_group_split.go | 100 ++++ internal/ui/editor_group_split_test.go | 19 + internal/ui/editor_layout.go | 300 ++++++++++ internal/ui/editor_layout_test.go | 77 +++ internal/ui/editor_widget_autoscroll.go | 1 + internal/ui/split_panel.go | 33 +- internal/ui/tabbar_widget.go | 11 + internal/ui/widget.go | 4 +- tests/e2e/editor_split_test.go | 525 ++++++++++++++++++ tests/e2e/harness_test.go | 9 +- tests/e2e/plugin_tab_focus_test.go | 29 + 57 files changed, 2214 insertions(+), 381 deletions(-) create mode 100644 internal/app/editor_group_menu.go create mode 100644 internal/app/editor_split.go create mode 100644 internal/app/editor_split_async_test.go create mode 100644 internal/app/editor_split_lifecycle_test.go create mode 100644 internal/app/theme_border_test.go create mode 100644 internal/ui/editor_group_split.go create mode 100644 internal/ui/editor_group_split_test.go create mode 100644 internal/ui/editor_layout.go create mode 100644 internal/ui/editor_layout_test.go create mode 100644 tests/e2e/editor_split_test.go diff --git a/README.md b/README.md index 8a2f3aacc..ef9d63fdd 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,8 @@ All keybindings are customizable via [`keybindings.json`](config/keybindings.jso Set [`keyboard.modifierKey`](docs-web/src/content/docs/reference/settings.md#keyboard) to `"control+super"` to also accept Super/Cmd for Ctrl shortcuts, or `"super"` to use Super/Cmd instead. The default is `"control"`. Binding definitions stay unchanged, and the terminal must forward the shortcuts. +Use the editor tab bar’s **More** menu to split right or down (up to four groups), or close a split. Each new group starts with an independent, numbered untitled document; opening a file replaces the untouched placeholder. The command palette groups split actions under **Split:**, including **Split: Equalize Sizes** and **Split: Move to Other Group**. Views of the same document share text and undo history, with independent cursors and scrolling. **Ctrl+K Left/Right/Up/Down** focuses the group in that direction; **F6** and **Shift+F6** cycle groups, the sidebar, and the panel. Drag tabs between groups, or use **Open to the Side** in the Explorer or tab menu. Closing the last tab removes its group; **Close Split** merges its remaining tabs into a neighbor. + The full default keybinding list is in [`docs-web/src/content/docs/reference/keybindings.md`](docs-web/src/content/docs/reference/keybindings.md). ## Testing diff --git a/config/keybindings.json b/config/keybindings.json index fa399879c..68b2ef13d 100644 --- a/config/keybindings.json +++ b/config/keybindings.json @@ -86,6 +86,10 @@ { "key": "f6", "command": "focus.nextGroup" }, { "key": "shift+f6", "command": "focus.prevGroup" }, { "key": "ctrl+f6", "command": "terminal.toggleShortcutForwarding" }, + { "key": "ctrl+k left", "command": "focus.editorLeft" }, + { "key": "ctrl+k right", "command": "focus.editorRight" }, + { "key": "ctrl+k up", "command": "focus.editorUp" }, + { "key": "ctrl+k down", "command": "focus.editorDown" }, { "key": "f10", "command": "menu.file" }, { "key": "alt+f", "command": "menu.file" }, { "key": "alt+e", "command": "menu.edit" }, diff --git a/config/settings.json b/config/settings.json index d7a60db87..fc4dbd1ec 100644 --- a/config/settings.json +++ b/config/settings.json @@ -4,7 +4,8 @@ "appearance": { "icons": "none", "scrollbarGlyphs": "blocks", - "pointerShapes": true + "pointerShapes": true, + "highlightActiveGroupBorder": true }, "editor": { "autoClosing": { diff --git a/docs-web/src/content/docs/guides/editor.md b/docs-web/src/content/docs/guides/editor.md index badc6e911..9cf7b14e8 100644 --- a/docs-web/src/content/docs/guides/editor.md +++ b/docs-web/src/content/docs/guides/editor.md @@ -11,6 +11,16 @@ On exit, the current text-file tab list replaces the remembered list for that wo Sessions are kept outside the project under the config directory's `sessions/` folder, with at most 128 tabs per session and 32 sessions. Sessions unused for 30 days are removed, and missing or non-regular files are skipped during restoration. **Session: Clear Saved Tabs** removes saved sessions without closing open files and pauses session saving until the next launch. +## Split Editors + +Use the editor tab bar's **More** menu to choose **Split Right** (side by side) or **Split Down** (stacked). Repeat on a group to create up to four editor groups, including a grid. Each new group starts with an independent untitled document, numbered across the workspace. Opening a file replaces that untouched placeholder; once edited, the untitled document stays in its own tab. + +Views of the same document share text, undo history, and save state, with independent cursors, selections, and scrolling. Opening a file normally uses the active group, including when the same document is already open elsewhere. Split commands use the **Split:** prefix in the command palette; the More menu keeps only **Split Right**, **Split Down**, and **Close Split** for managing splits. + +Click a group or press **Ctrl+K Left/Right/Up/Down** to focus it. The active group’s border is highlighted by default; turn off **Highlight active split** in **Settings > Appearance > Window** to disable it. **F6** and **Shift+F6** cycle through editor groups, the sidebar, and the panel. + +Drag dividers alongside editor content to resize groups; side-by-side dividers leave the tab rows clear. **Split: Equalize Sizes** in the command palette balances their sizes again. Closing a group's last tab removes that group; **Close Split** merges its tabs into a neighboring group, keeping one view when both contain the same document. + ## Syntax Highlighting TTT highlights code with [textmate-go](https://github.com/eugenioenko/textmate-go), a pure Go port of VS Code's TextMate engine built for ttt. It embeds TextMate grammars for 124 languages, detected from the file name, and colors constructs that span lines (block comments, template literals, docstrings, tags with attributes on several lines) correctly. @@ -86,8 +96,8 @@ They have no default shortcut. Bind them in `keybindings.json`, for example: ```json [ - { "key": "ctrl+k up", "command": "view.scrollUp" }, - { "key": "ctrl+k down", "command": "view.scrollDown" } + { "key": "ctrl+k pgup", "command": "view.scrollUp" }, + { "key": "ctrl+k pgdn", "command": "view.scrollDown" } ] ``` diff --git a/docs-web/src/content/docs/reference/keybindings.md b/docs-web/src/content/docs/reference/keybindings.md index f0282fad2..adbc58bdb 100644 --- a/docs-web/src/content/docs/reference/keybindings.md +++ b/docs-web/src/content/docs/reference/keybindings.md @@ -21,8 +21,12 @@ In-app shortcut labels use `Ctrl` in Control mode, `Mod` (Ctrl or Super) in Both | Ctrl+P | `command.palette` | Open command palette | | Ctrl+K P | `file.quickOpen` | Quick open file | | Escape | *(built-in)* | Dismiss overlay / collapse cursors / focus the editor | -| F6 | `focus.nextGroup` | Focus next group (editor → sidebar → bottom panel → editor; hidden groups are skipped) | -| Shift+F6 | `focus.prevGroup` | Focus previous group (reverse order) | +| F6 | `focus.nextGroup` | Focus next editor group, sidebar, or panel | +| Shift+F6 | `focus.prevGroup` | Focus previous group | +| Ctrl+K Left | `focus.editorLeft` | Focus editor group left | +| Ctrl+K Right | `focus.editorRight` | Focus editor group right | +| Ctrl+K Up | `focus.editorUp` | Focus editor group up | +| Ctrl+K Down | `focus.editorDown` | Focus editor group down | ## File diff --git a/docs-web/src/content/docs/reference/settings.md b/docs-web/src/content/docs/reference/settings.md index 31b6123b7..9d99af50d 100644 --- a/docs-web/src/content/docs/reference/settings.md +++ b/docs-web/src/content/docs/reference/settings.md @@ -71,6 +71,7 @@ Lua plugin key hooks receive enabled Super/Cmd shortcuts as Ctrl shortcuts in `" | `appearance.overrides` | object | `{}` | Replace any UI glyph, keyed by icon name (see [Icon overrides](#icon-overrides)). An override wins over both the plain and Nerd Font glyph. | | `appearance.scrollbarGlyphs` | string | `"blocks"` | Glyphs that draw git change marks on the editor scrollbar: `"blocks"` uses the half and eighth blocks every font has, which place a mark exactly near the top or bottom of a cell and stretch one in the middle to the nearer edge; `"legacy"` adds the Symbols for Legacy Computing thin bars, which place every mark to the exact eighth of a cell. Kitty, Ghostty, WezTerm and foot draw those themselves; elsewhere the font needs them or the marks show as boxes. | | `appearance.pointerShapes` | bool | `true` | Change the mouse pointer shape (I-beam, hand, resize divider, dragging hand) via terminal OSC 22 sequences. Set to `false` for compatibility with terminals or multiplexers that do not support OSC 22 or if you prefer a static pointer. Mouse clicks, selection, scrolling, drag gestures, and mouse reporting remain enabled when false. | +| `appearance.highlightActiveGroupBorder` | bool | `true` | Highlight the active editor group’s border when editors are split. Changes apply immediately. Also in **Settings > Appearance > Window**. | ## Icon overrides @@ -450,7 +451,8 @@ When `editor.formatOnSave` is `true`, external formatters take priority over LSP "appearance": { "icons": "none", "scrollbarGlyphs": "blocks", - "pointerShapes": true + "pointerShapes": true, + "highlightActiveGroupBorder": true }, "editor": { "tabSize": 4, diff --git a/internal/app/app.go b/internal/app/app.go index b2f6430f5..65a0aa53b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -66,7 +66,8 @@ type TerminalSpec struct { type App struct { language languageState Root *ui.Root - EditorGroup *ui.EditorGroupWidget + EditorGroup *ui.EditorGroupWidget // Follows activation so commands target the group receiving keys. + EditorSplit *ui.EditorLayoutWidget Sidebar *ui.SidebarWidget SplitPanel *ui.SplitPanelWidget ContentSplit *ui.ContentSplitWidget @@ -777,12 +778,7 @@ func (a *App) Init(screen *term.TcellScreen, renderer *render.Renderer, lspManag screen.PostEvent(tcell.NewEventInterrupt(nil)) }) a.RefreshImageCellSize() - a.EditorGroup.TabBar.PostDragAutoScrollTick = func(generation uint64) { - screen.PostEvent(tcell.NewEventInterrupt(&ui.TabDragAutoScrollTick{Generation: generation})) - } - a.EditorGroup.Editor.PostDragAutoScrollTick = func(generation uint64) { - screen.PostEvent(tcell.NewEventInterrupt(&ui.EditorDragAutoScrollTick{Generation: generation})) - } + a.wireEditorAutoScroll(a.EditorGroup) highlight.SetStepScheduler(func() { screen.PostEvent(tcell.NewEventInterrupt(&highlightStep{})) }) @@ -946,6 +942,7 @@ func openURLCommand(rawURL, platform string) *exec.Cmd { } func (a *App) FlushEditorOnChange() { + a.EditorGroup.SyncDocumentViews() if a.EditorGroup.Editor != nil { a.EditorGroup.Editor.FlushOnChange() } diff --git a/internal/app/app_lsp.go b/internal/app/app_lsp.go index aa3bef0f2..4d1a6a899 100644 --- a/internal/app/app_lsp.go +++ b/internal/app/app_lsp.go @@ -640,7 +640,7 @@ func (a *App) applyInteractiveCodeActionEdit(path string, edit *lsp.WorkspaceEdi for _, batch := range batches { batchPath := URIToPath(batch.URI) captured, wasOpen := openBuffers[batchPath] - current := a.EditorGroup.BufferForPath(batchPath) + current := a.editorBufferForPath(batchPath) if (!wasOpen && current != nil) || (wasOpen && (current != captured.Buffer || current == nil || current.Version() != captured.Version)) { return false, false @@ -652,9 +652,11 @@ func (a *App) applyInteractiveCodeActionEdit(path string, edit *lsp.WorkspaceEdi func (a *App) snapshotCodeActionBuffers() map[string]codeActionBufferSnapshot { snapshots := make(map[string]codeActionBufferSnapshot) - for _, path := range a.EditorGroup.OpenFilePaths() { - if buf := a.EditorGroup.BufferForPath(path); buf != nil { - snapshots[path] = codeActionBufferSnapshot{Buffer: buf, Version: buf.Version()} + for _, group := range a.editorGroups() { + for _, path := range group.OpenFilePaths() { + if buf := group.BufferForPath(path); buf != nil { + snapshots[path] = codeActionBufferSnapshot{Buffer: buf, Version: buf.Version()} + } } } return snapshots diff --git a/internal/app/callbacks.go b/internal/app/callbacks.go index 1c4992ef0..f18b855c4 100644 --- a/internal/app/callbacks.go +++ b/internal/app/callbacks.go @@ -276,7 +276,7 @@ func joinReplacedLines(lines []string, original []byte) []byte { } func (a *App) ApplySearchReplace(filePath string, matches []ui.SearchMatch, replacement string, opts ui.SearchOptions) { - if a.EditorGroup.IsDirtyPath(filePath) { + if a.isEditorPathDirty(filePath) { a.StatusWarn(i18n.Format("status.hasUnsavedChangesSaveItBeforeReplacing", i18n.Args{"filename": filepath.Base(filePath)})) return } @@ -300,7 +300,7 @@ func (a *App) ApplySearchReplace(filePath string, matches []ui.SearchMatch, repl return } a.invalidateRepositoryPath(filePath, RepositoryWorktree) - a.EditorGroup.ReloadFile(filePath) + a.reloadFile(filePath) a.Search.Refresh() a.StatusNotify(i18n.Format("status.replacedMatchesIn", i18n.Args{"matchesCount": len(matches), "filename": filepath.Base(filePath)})) } @@ -332,7 +332,7 @@ func (a *App) confirmSearchReplaceAll(allMatches map[string][]ui.SearchMatch, re replacedMatches, replacedFiles := 0, 0 var skipped []string for filePath, matches := range allMatches { - if a.EditorGroup.IsDirtyPath(filePath) { + if a.isEditorPathDirty(filePath) { skipped = append(skipped, filepath.Base(filePath)) continue } @@ -357,7 +357,7 @@ func (a *App) confirmSearchReplaceAll(allMatches map[string][]ui.SearchMatch, re if invalidatePath == "" { invalidatePath = filePath } - a.EditorGroup.ReloadFile(filePath) + a.reloadFile(filePath) } a.invalidateRepositoryPath(invalidatePath, RepositoryWorktree) a.Search.Refresh() @@ -561,6 +561,7 @@ func (a *App) fetchPRFileContent(dv *ui.DiffEditorWidget, owner, repo, baseSHA, fetchErr = err } a.Screen.PostEvent(tcell.NewEventInterrupt(&DiffContentResult{ + Widget: dv, TabName: tabName, OldLines: oldLines, NewLines: newLines, @@ -730,16 +731,9 @@ func registerWidgetCallbacks(app *App) { app.Root.SetFocus(app.EditorGroup) } - app.EditorGroup.OnActiveTabChange = func() { - app.Root.ClearEditorLinkHover() - if findBar, ok := app.Root.TopOverlayWidget().(*ui.FindBarWidget); ok && findBar.OnEditorTabChange != nil { - findBar.OnEditorTabChange() - } - if bar := app.findBar(); bar != nil && bar.Query() != "" { - return - } - app.applySearchHighlights() - } + app.wireEditorGroupActivation(app.EditorGroup) + + app.EditorGroup.OnActiveTabChange = app.editorTabChanged app.EditorGroup.TabBar.OnTabClose = func(index int) { app.EditorGroup.SwitchTab(index) @@ -786,11 +780,6 @@ func registerWidgetCallbacks(app *App) { } app.Explorer.OnAction = func(id string) { reg.Execute(id) } - app.EditorGroup.OnEmpty = func() { - if app.welcomeWhenEmpty && len(app.Workspace.Paths()) == 0 { - app.ShowEmptyState() - } - } app.Explorer.OnOpenFile = func(path string) { app.EditorGroup.OpenFile(path) app.FocusEditorIfEnabled() @@ -888,12 +877,7 @@ func registerWidgetCallbacks(app *App) { app.ContentSplit.OnResize = app.resizePanel app.ContentSplit.OnResizeEnd = app.finishPanelResize - app.ContentSplit.OnTopClick = func() { - if app.Root.HasModalOverlay() || app.Root.FocusClaimed() { - return - } - app.Root.SetFocus(app.EditorGroup) - } + app.ContentSplit.OnTopClick = app.focusEditorAfterClick app.ContentSplit.OnBottomClick = func() { if app.Root.HasModalOverlay() || app.Root.FocusClaimed() { @@ -970,10 +954,23 @@ func registerWidgetCallbacks(app *App) { } func (a *App) tabMoreMenu() []ui.ContextMenuItem { - return []ui.ContextMenuItem{ + items := []ui.ContextMenuItem{ {Label: i18n.Text("menu.reopenClosedTab"), Shortcut: a.KeyFor("tab.reopenClosed"), Command: "tab.reopenClosed", Disabled: !a.EditorGroup.HasClosedTabs()}, ui.MenuSep(), {Label: i18n.Text("menu.closeAll"), Command: "tab.closeAll"}, {Label: i18n.Text("menu.closeAllSaved"), Command: "tab.closeAllSaved"}, + ui.MenuSep(), + } + return append(items, a.editorGroupMenu()...) +} + +func (app *App) editorTabChanged() { + app.Root.ClearEditorLinkHover() + if findBar, ok := app.Root.TopOverlayWidget().(*ui.FindBarWidget); ok && findBar.OnEditorTabChange != nil { + findBar.OnEditorTabChange() + } + if bar := app.findBar(); bar != nil && bar.Query() != "" { + return } + app.applySearchHighlights() } diff --git a/internal/app/changes_async.go b/internal/app/changes_async.go index b124b71b2..a9d01dcc4 100644 --- a/internal/app/changes_async.go +++ b/internal/app/changes_async.go @@ -563,10 +563,13 @@ func readCommitDetail(ctx context.Context, incarnation uint64, source repository func (a *App) OpenCommitDetail(dir, ref, short string) { a.cancelPendingDiff() tabID := commitDetailTabID(dir, ref) - if a.EditorGroup.CommitDetailWidgetByTab(tabID) != nil { - a.EditorGroup.SwitchToTabByPath(tabID) - a.FocusEditorIfEnabled() - return + for _, group := range a.editorGroups() { + if group.CommitDetailWidgetByTab(tabID) != nil { + a.activateEditorGroup(group) + group.SwitchToTabByPath(tabID) + a.FocusEditorIfEnabled() + return + } } source := a.Repository.repositorySource(dir) @@ -636,14 +639,24 @@ func (a *App) ShutdownGitReads() { } } +func (a *App) commitDetailForResult(tabID, dir, ref string, incarnation uint64) *ui.CommitDetailWidget { + for _, group := range a.editorGroups() { + detail := group.CommitDetailWidgetByTab(tabID) + if detail != nil && detail.Dir == dir && detail.Ref == ref && detail.Incarnation == incarnation { + return detail + } + } + return nil +} + // ApplyCommitDetail fills only the still-open incarnation whose repository and // full hash match the result. Closing the tab while Git runs supersedes it. func (a *App) ApplyCommitDetail(result *CommitDetailResult) { if result == nil || result.Canceled { return } - detail := a.EditorGroup.CommitDetailWidgetByTab(commitDetailTabID(result.Dir, result.Ref)) - if detail == nil || detail.Dir != result.Dir || detail.Ref != result.Ref || detail.Incarnation != result.Incarnation { + detail := a.commitDetailForResult(commitDetailTabID(result.Dir, result.Ref), result.Dir, result.Ref, result.Incarnation) + if detail == nil { return } if !result.AuthoredAt.IsZero() { diff --git a/internal/app/commands_editor.go b/internal/app/commands_editor.go index de619282b..e22ede465 100644 --- a/internal/app/commands_editor.go +++ b/internal/app/commands_editor.go @@ -99,7 +99,7 @@ func (a *App) FormatSelection() { } func (a *App) CloseTab() { - if !a.EditorGroup.IsDirty() { + if !a.EditorGroup.IsDirty() || a.EditorGroup.HasOtherDocumentView() { a.EditorGroup.CloseTab() return } @@ -270,7 +270,10 @@ func (a *App) saveFileAs(path string, onSaved func()) { } if a.EditorGroup.SaveAs(path) { path, lang := a.editorPathLang() - if len(targets) > 0 { + a.DocVersionsMu.Lock() + _, opened := a.DocVersions[path] + a.DocVersionsMu.Unlock() + if len(targets) > 0 || opened { a.NotifyLSPChange(path, lang, a.EditorGroup.ActiveBuffer().Lines) } a.afterFileSave(path, lang) @@ -420,7 +423,11 @@ func (a *App) Quit() { } func (a *App) quitWithAction(onQuit func()) { - if !a.EditorGroup.AnyDirty() { + dirty := false + for _, group := range a.editorGroups() { + dirty = dirty || group.AnyDirty() + } + if !dirty { onQuit() return } @@ -431,10 +438,12 @@ func (a *App) quitWithAction(onQuit func()) { callbacks := []func(){cancel, onQuit} showSaveAll := a.Settings.Editor.SaveAllOnQuit if a.Settings.Editor.SaveAllOnQuitExceptUnnamed { - for i := 0; i < a.EditorGroup.TabCount(); i++ { - if a.EditorGroup.IsTabUnnamed(i) { - showSaveAll = false - break + for _, group := range a.editorGroups() { + for i := 0; i < group.TabCount(); i++ { + if group.IsTabUnnamed(i) { + showSaveAll = false + break + } } } } @@ -473,6 +482,14 @@ func (a *App) saveAllAndQuit(onQuit func(), previousOverlay ui.Widget) { } } if next < 0 { + for _, group := range a.editorGroups() { + if group != a.EditorGroup && group.AnyDirty() { + a.activateEditorGroup(group) + a.FocusEditor() + a.saveAllAndQuit(onQuit, previousOverlay) + return + } + } onQuit() return } diff --git a/internal/app/commands_plugin.go b/internal/app/commands_plugin.go index 3e1dcde91..0b62f1b5a 100644 --- a/internal/app/commands_plugin.go +++ b/internal/app/commands_plugin.go @@ -196,8 +196,10 @@ func (a *App) pluginUninstall() { // ClearPluginSources removes the diagnostics, decorations and status items plugin name set. func (a *App) ClearPluginSources(name string) { - a.EditorGroup.ClearDiagnosticsSource("plugin:" + name) - a.EditorGroup.ClearDecorationsSource("plugin:" + name) + for _, group := range a.editorGroups() { + group.ClearDiagnosticsSource("plugin:" + name) + group.ClearDecorationsSource("plugin:" + name) + } a.Status.RemoveSegmentsByOwner("plugin:" + name) } @@ -587,14 +589,20 @@ func (a *App) WirePlugin(p *plugin.Plugin) { Source: it.Source, }) } - a.EditorGroup.SetDiagnosticsSource(pluginSource, path, diags) + for _, group := range a.editorGroups() { + group.SetDiagnosticsSource(pluginSource, path, diags) + } a.Screen.PostEvent(tcell.NewEventInterrupt(nil)) } p.ClearDiagnostics = func(path string) { if path == "" { - a.EditorGroup.ClearDiagnosticsSource(pluginSource) + for _, group := range a.editorGroups() { + group.ClearDiagnosticsSource(pluginSource) + } } else { - a.EditorGroup.SetDiagnosticsSource(pluginSource, path, nil) + for _, group := range a.editorGroups() { + group.SetDiagnosticsSource(pluginSource, path, nil) + } } a.Screen.PostEvent(tcell.NewEventInterrupt(nil)) } @@ -611,14 +619,20 @@ func (a *App) WirePlugin(p *plugin.Plugin) { Style: it.Style, } } - a.EditorGroup.SetDecorations(pluginSource, path, decos) + for _, group := range a.editorGroups() { + group.SetDecorations(pluginSource, path, decos) + } a.Screen.PostEvent(tcell.NewEventInterrupt(nil)) } p.ClearDecorations = func(path string) { if path == "" { - a.EditorGroup.ClearDecorationsSource(pluginSource) + for _, group := range a.editorGroups() { + group.ClearDecorationsSource(pluginSource) + } } else { - a.EditorGroup.SetDecorations(pluginSource, path, nil) + for _, group := range a.editorGroups() { + group.SetDecorations(pluginSource, path, nil) + } } a.Screen.PostEvent(tcell.NewEventInterrupt(nil)) } @@ -781,7 +795,9 @@ func (a *App) WirePlugin(p *plugin.Plugin) { a.registerPluginCommandsAndKeys(p) p.FlushPendingDrawer() if len(p.FileHandlers) > 0 { - a.EditorGroup.RetryFileHandlers() + for _, group := range a.editorGroups() { + group.RetryFileHandlers() + } } } @@ -1225,34 +1241,37 @@ func (a *App) ShowPluginPanel(name, area string, focus bool) (bool, error) { return true, nil } -func (a *App) pluginTab(name, key string) (int, *plugin.PluginPanelWidget) { - for i := range a.EditorGroup.TabCount() { - panel, ok := a.EditorGroup.TabContent(i).(*plugin.PluginPanelWidget) - if ok && panel.PluginName() == name && a.EditorGroup.PluginTabKey(i) == key { - return i, panel +func (a *App) pluginTab(name, key string) (*ui.EditorGroupWidget, int, *plugin.PluginPanelWidget) { + for _, group := range a.editorGroups() { + for i := range group.TabCount() { + panel, ok := group.TabContent(i).(*plugin.PluginPanelWidget) + if ok && panel.PluginName() == name && group.PluginTabKey(i) == key { + return group, i, panel + } } } - return -1, nil + return nil, -1, nil } func (a *App) switchToPluginTab(name, key string, focus bool) bool { - i, panel := a.pluginTab(name, key) + group, i, panel := a.pluginTab(name, key) if i < 0 { return false } - a.EditorGroup.SwitchTab(i) + group.SwitchTab(i) if focus && !a.Root.HasModalOverlay() { - a.Root.ClaimFocus(a.EditorGroup) + a.Root.ClaimFocus(group) panel.FocusAutofocusInput() } return true } func (a *App) closePluginTab(name, key string) bool { - if i, _ := a.pluginTab(name, key); i < 0 { + group, i, _ := a.pluginTab(name, key) + if i < 0 { return false } - return a.EditorGroup.ClosePluginTab(key) + return group.ClosePluginTab(key) } // Panel images live in the terminal until released, so a plugin panel is closed whenever it leaves the UI. @@ -1271,17 +1290,19 @@ func (a *App) closePluginDrawerAndTabs(name string) { a.ClosePluginDrawer() } // File-handler tabs are left to SyncFileHandlerTabs, which falls back to the binary card. - var keys []string - for i := range a.EditorGroup.TabCount() { - path, _ := a.EditorGroup.TabInfo(i) - if panel, ok := a.EditorGroup.TabContent(i).(*plugin.PluginPanelWidget); ok && panel.PluginName() == name { - if _, handled := a.fileHandlerTabs[path]; !handled { - keys = append(keys, a.EditorGroup.PluginTabKey(i)) + for _, group := range a.editorGroups() { + var keys []string + for i := range group.TabCount() { + path, _ := group.TabInfo(i) + if panel, ok := group.TabContent(i).(*plugin.PluginPanelWidget); ok && panel.PluginName() == name { + if _, handled := a.fileHandlerTabs[path]; !handled { + keys = append(keys, group.PluginTabKey(i)) + } } } - } - for _, key := range keys { - a.EditorGroup.ClosePluginTab(key) + for _, key := range keys { + group.ClosePluginTab(key) + } } } diff --git a/internal/app/commands_settings.go b/internal/app/commands_settings.go index 65b5a68b8..867dfd083 100644 --- a/internal/app/commands_settings.go +++ b/internal/app/commands_settings.go @@ -106,7 +106,9 @@ func (a *App) applyIcons(ap config.AppearanceSettings) { a.Search.ChevronExpanded = expanded } if a.EditorGroup != nil { - a.EditorGroup.SetChevrons(collapsed, expanded) + for _, group := range a.editorGroups() { + group.SetChevrons(collapsed, expanded) + } } } @@ -173,55 +175,13 @@ func (a *App) ApplySettings(s config.Settings) { } highlight.SetTimeLimit(time.Duration(s.Editor.HighlightTimeLimit) * time.Millisecond) - // Apply editor settings to the editor group and active editor - a.EditorGroup.TabSize = s.Editor.TabSize - a.EditorGroup.SetFileIconMode(s.Appearance.Icons) - a.EditorGroup.InsertSpaces = s.Editor.InsertSpaces - a.EditorGroup.LineNumbers = s.Editor.LineNumbers - a.EditorGroup.SetIndentGuides(s.Editor.IndentGuides) - a.EditorGroup.GutterStyle = s.Editor.GutterStyle - a.EditorGroup.InsertFinalNewline = s.Editor.InsertFinalNewline - a.EditorGroup.ShowTrailingNewline = s.Editor.IsShowTrailingNewlineEnabled() - a.EditorGroup.TrimTrailingWhitespace = s.Editor.TrimTrailingWhitespace - a.EditorGroup.WordWrap = s.Editor.WordWrap - a.EditorGroup.PreviewTabs = s.Editor.PreviewTabsEnabled() - a.EditorGroup.WordWrapColumn = s.Editor.WordWrapColumn - a.EditorGroup.WordWrapBreak = s.Editor.WordWrapBreak - a.EditorGroup.LanguageOverrides = s.Editor.LanguageOverrides - a.EditorGroup.WordWrapMaxBytes = s.Editor.WordWrapMaxBytes() - a.EditorGroup.SetDiffDefaults(configuredDiffMode(s.Editor.DiffMode), configuredDiffContext(s.Editor.DiffContext), s.Editor.DiffWordWrap) - a.EditorGroup.SetMarkdownMaxWidth(s.Markdown.WrapWidth) - a.EditorGroup.SetDiffHighContrast(s.Editor.DiffHighContrast) - a.EditorGroup.SetDiffCollapsedEmphasis(s.Editor.DiffCollapsedEmphasis) - a.EditorGroup.SetDiffSigns(s.Editor.DiffSigns) - a.EditorGroup.SetDiffSignsColor(s.Editor.DiffSignsColor) - a.EditorGroup.SetDiffGutterHighlight(s.Editor.DiffGutterHighlight) - a.EditorGroup.SetDiffColorGutterNumbers(s.Editor.DiffColorGutterNumbers) - a.EditorGroup.SetDiffScrollMarks(s.Editor.DiffScrollbarGitMarks, s.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy) - a.EditorGroup.BracketPairColorization = s.Editor.BracketPairColorization - a.EditorGroup.UndoDeleteCursorStart = s.Editor.UndoDeleteCursorStart - a.EditorGroup.ApplyUndoDeleteCursorStart(s.Editor.UndoDeleteCursorStart) - a.EditorGroup.SetImageProtocol(s.Image.Protocol) + for _, group := range a.editorGroups() { + applyEditorGroupSettings(group, s, prev) + } if a.Sidebar != nil { a.Sidebar.SetPanelOrder(s.Sidebar.PanelOrder) } - if a.EditorGroup.Editor != nil { - a.EditorGroup.Editor.TabSize = s.Editor.TabSize - a.EditorGroup.Editor.LineNumbers = s.Editor.LineNumbers - a.EditorGroup.Editor.IndentGuides = s.Editor.IndentGuides - a.EditorGroup.Editor.GutterStyle = s.Editor.GutterStyle - a.EditorGroup.Editor.LegacyScrollbarGlyphs = s.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy - a.EditorGroup.Editor.ScrollbarGitMarks = s.Editor.IsScrollbarGitMarksEnabled() - a.EditorGroup.Editor.AutoDedent = s.Editor.IsAutoDedentEnabled() - a.EditorGroup.Editor.AutoIndent = s.Editor.IsAutoIndentEnabled() - a.EditorGroup.Editor.AutoClosing = s.Editor.AutoClosing - a.EditorGroup.Editor.BracketPairColorization = s.Editor.BracketPairColorization - if s.Editor.BracketPairColorization != prev.Editor.BracketPairColorization { - a.EditorGroup.Editor.InvalidateBracketColors() - } - } - a.applyMenuBarVisibility(s.Editor.IsMenuBarVisible()) // Apply cursor style @@ -242,7 +202,9 @@ func (a *App) ApplySettings(s config.Settings) { if s.Editor.IsGitGutterEnabled() != prev.Editor.IsGitGutterEnabled() { if !s.Editor.IsGitGutterEnabled() && a.EditorGroup.Editor != nil { - a.EditorGroup.Editor.LineChanges = nil + for _, group := range a.editorGroups() { + group.Editor.LineChanges = nil + } } a.RequestGitGutterForActiveFile() } @@ -250,7 +212,9 @@ func (a *App) ApplySettings(s config.Settings) { a.applyIcons(s.Appearance) a.applySidebarTabStyle(s.Sidebar.TabStyle) a.applyGrammarSettings(s.Editor.Grammars) - a.EditorGroup.ApplyFileWordWrap() + for _, group := range a.editorGroups() { + group.ApplyFileWordWrap() + } if a.Explorer != nil && (a.Explorer.Settings != s.Explorer || a.Explorer.Icons != s.Appearance.Icons) { a.Explorer.Settings = s.Explorer @@ -310,3 +274,51 @@ func registerSettingsCommands(app *App) { Handler: app.ReloadSettings, }) } + +func applyEditorGroupSettings(group *ui.EditorGroupWidget, s, prev config.Settings) { + group.HighlightActiveBorder = s.Appearance.HighlightActiveGroupBorder + group.TabSize = s.Editor.TabSize + group.SetFileIconMode(s.Appearance.Icons) + group.InsertSpaces = s.Editor.InsertSpaces + group.LineNumbers = s.Editor.LineNumbers + group.SetIndentGuides(s.Editor.IndentGuides) + group.GutterStyle = s.Editor.GutterStyle + group.InsertFinalNewline = s.Editor.InsertFinalNewline + group.ShowTrailingNewline = s.Editor.IsShowTrailingNewlineEnabled() + group.TrimTrailingWhitespace = s.Editor.TrimTrailingWhitespace + group.WordWrap = s.Editor.WordWrap + group.PreviewTabs = s.Editor.PreviewTabsEnabled() + group.WordWrapColumn = s.Editor.WordWrapColumn + group.WordWrapBreak = s.Editor.WordWrapBreak + group.LanguageOverrides = s.Editor.LanguageOverrides + group.WordWrapMaxBytes = s.Editor.WordWrapMaxBytes() + group.SetDiffDefaults(configuredDiffMode(s.Editor.DiffMode), configuredDiffContext(s.Editor.DiffContext), s.Editor.DiffWordWrap) + group.SetMarkdownMaxWidth(s.Markdown.WrapWidth) + group.SetDiffHighContrast(s.Editor.DiffHighContrast) + group.SetDiffCollapsedEmphasis(s.Editor.DiffCollapsedEmphasis) + group.SetDiffSigns(s.Editor.DiffSigns) + group.SetDiffSignsColor(s.Editor.DiffSignsColor) + group.SetDiffGutterHighlight(s.Editor.DiffGutterHighlight) + group.SetDiffColorGutterNumbers(s.Editor.DiffColorGutterNumbers) + group.SetDiffScrollMarks(s.Editor.DiffScrollbarGitMarks, s.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy) + group.BracketPairColorization = s.Editor.BracketPairColorization + group.UndoDeleteCursorStart = s.Editor.UndoDeleteCursorStart + group.ApplyUndoDeleteCursorStart(s.Editor.UndoDeleteCursorStart) + group.SetImageProtocol(s.Image.Protocol) + + if group.Editor != nil { + group.Editor.TabSize = s.Editor.TabSize + group.Editor.LineNumbers = s.Editor.LineNumbers + group.Editor.IndentGuides = s.Editor.IndentGuides + group.Editor.GutterStyle = s.Editor.GutterStyle + group.Editor.LegacyScrollbarGlyphs = s.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy + group.Editor.ScrollbarGitMarks = s.Editor.IsScrollbarGitMarksEnabled() + group.Editor.AutoDedent = s.Editor.IsAutoDedentEnabled() + group.Editor.AutoIndent = s.Editor.IsAutoIndentEnabled() + group.Editor.AutoClosing = s.Editor.AutoClosing + group.Editor.BracketPairColorization = s.Editor.BracketPairColorization + if s.Editor.BracketPairColorization != prev.Editor.BracketPairColorization { + group.Editor.InvalidateBracketColors() + } + } +} diff --git a/internal/app/commands_view.go b/internal/app/commands_view.go index 43237f400..b75e60be1 100644 --- a/internal/app/commands_view.go +++ b/internal/app/commands_view.go @@ -44,64 +44,32 @@ func (a *App) focusedRegion() string { return "editor" } -func (a *App) focusNextGroup() { - if a.Root.HasModalOverlay() { - return - } - regions := []string{"editor"} - if a.Sidebar.Visible { - regions = append(regions, "sidebar") - } - if a.ContentSplit.ShowBottom { - regions = append(regions, "bottom") - } - current := a.focusedRegion() - for i, r := range regions { - if r == current { - next := regions[(i+1)%len(regions)] - a.focusRegion(next) - return - } - } - a.FocusEditor() -} +func (a *App) focusNextGroup() { a.cycleFocusGroup(1) } +func (a *App) focusPrevGroup() { a.cycleFocusGroup(-1) } -func (a *App) focusPrevGroup() { +func (a *App) cycleFocusGroup(direction int) { if a.Root.HasModalOverlay() { return } - regions := []string{"editor"} + var groups []ui.Widget + for _, group := range a.editorGroups() { + groups = append(groups, group) + } if a.Sidebar.Visible { - regions = append(regions, "sidebar") + groups = append(groups, a.Sidebar.ActiveWidget()) } if a.ContentSplit.ShowBottom { - regions = append(regions, "bottom") + groups = append(groups, a.BottomPanel.ActiveWidget()) } - current := a.focusedRegion() - for i, r := range regions { - if r == current { - prev := i - 1 - if prev < 0 { - prev = len(regions) - 1 - } - a.focusRegion(regions[prev]) + for i, group := range groups { + if group == a.Root.Focused || (a.focusedRegion() == "sidebar" && group == a.Sidebar.ActiveWidget()) || (a.focusedRegion() == "bottom" && group == a.BottomPanel.ActiveWidget()) { + a.Root.SetFocus(groups[(i+direction+len(groups))%len(groups)]) return } } a.FocusEditor() } -func (a *App) focusRegion(region string) { - switch region { - case "editor": - a.FocusEditor() - case "sidebar": - a.FocusSidebar() - case "bottom": - a.FocusPanel() - } -} - func (a *App) ScrollUp() { a.scrollEditor(-1) } func (a *App) ScrollDown() { a.scrollEditor(1) } func (a *App) ScrollLineUp() { a.EditorGroup.ScrollViewportLines(-1) } @@ -295,6 +263,14 @@ func (a *App) ToggleSearchReplace() { } func registerViewCommands(app *App) { + app.Reg.Register(command.Command{ID: "editor.split", Title: i18n.Text("command.splitEditor"), Handler: app.SplitEditor}) + app.Reg.Register(command.Command{ID: "editor.splitDown", Title: i18n.Text("command.splitEditorDown"), Handler: app.SplitEditorDown}) + app.Reg.Register(command.Command{ID: "editor.closeSplit", Title: i18n.Text("command.closeSplit"), Handler: app.CloseEditorSplit}) + app.Reg.Register(command.Command{ID: "editor.equalizeGroups", Title: i18n.Text("command.equalizeGroups"), Handler: app.EqualizeEditorGroups}) + app.Reg.Register(command.Command{ID: "focus.editorLeft", Title: i18n.Text("command.focusEditorLeft"), Handler: app.FocusEditorLeft}) + app.Reg.Register(command.Command{ID: "focus.editorRight", Title: i18n.Text("command.focusEditorRight"), Handler: app.FocusEditorRight}) + app.Reg.Register(command.Command{ID: "focus.editorUp", Title: i18n.Text("command.focusEditorUp"), Handler: app.FocusEditorUp}) + app.Reg.Register(command.Command{ID: "focus.editorDown", Title: i18n.Text("command.focusEditorDown"), Handler: app.FocusEditorDown}) reg := app.Reg reg.Register(command.Command{ diff --git a/internal/app/commit_detail_context.go b/internal/app/commit_detail_context.go index a2ec58e33..d990157d4 100644 --- a/internal/app/commit_detail_context.go +++ b/internal/app/commit_detail_context.go @@ -114,8 +114,8 @@ func (a *App) ApplyCommitDetailContext(result *CommitDetailContextResult) { if result == nil || result.Canceled { return } - detail := a.EditorGroup.CommitDetailWidgetByTab(result.TabID) - if detail == nil || detail.Dir != result.Dir || detail.Ref != result.Ref || detail.Incarnation != result.Incarnation { + detail := a.commitDetailForResult(result.TabID, result.Dir, result.Ref, result.Incarnation) + if detail == nil { return } applied := detail.ApplyFileContextContent(result.FileIndex, result.FileKey, result.OldLines, result.NewLines, result.ContentKind, result.Err) diff --git a/internal/app/debug_dump.go b/internal/app/debug_dump.go index 91fbe6a48..04996fad1 100644 --- a/internal/app/debug_dump.go +++ b/internal/app/debug_dump.go @@ -407,10 +407,19 @@ func walkWidget(w widgets.Widget) *DebugWidgetNode { if v.Right != nil { node.Children = append(node.Children, walkWidget(v.Right)) } + case *ui.EditorLayoutWidget: + node.Props = map[string]any{"groups": len(v.Groups())} + for _, child := range v.WidgetChildren() { + node.Children = append(node.Children, walkWidget(child)) + } case *ui.ContentSplitWidget: node.Props = map[string]any{ "show_bottom": v.ShowBottom, "bottom_h": v.BottomH, + "position": v.Position, + "ratio": v.BottomRatio, + "divider_x": v.DividerScreenX(), + "divider_y": v.DividerScreenY(), } if v.Top != nil { node.Children = append(node.Children, walkWidget(v.Top)) @@ -442,14 +451,19 @@ func walkWidget(w widgets.Widget) *DebugWidgetNode { paths[i], _ = v.TabInfo(i) } node.Props = map[string]any{ - "active_tab": v.ActiveTabIndex(), - "tabs": paths, - "path": v.ActiveFilePath(), + "active_tab": v.ActiveTabIndex(), + "active_group": v.TabBar.GroupActive, + "tabs": paths, + "path": v.ActiveFilePath(), } if v.Editor != nil { line, col := v.ActiveCursor() node.Props["cursor_line"] = line node.Props["cursor_col"] = col + if v.Editor.Viewport != nil { + node.Props["scroll_top"] = v.Editor.Viewport.TopLine + node.Props["scroll_left"] = v.Editor.Viewport.LeftCol + } } case *ui.StatusBarWidget: node.Props = map[string]any{} diff --git a/internal/app/editor_group_menu.go b/internal/app/editor_group_menu.go new file mode 100644 index 000000000..81038d396 --- /dev/null +++ b/internal/app/editor_group_menu.go @@ -0,0 +1,20 @@ +package app + +import ( + "github.com/eugenioenko/ttt/internal/i18n" + "github.com/eugenioenko/ttt/internal/ui" +) + +func (a *App) editorGroupMenu() []ui.ContextMenuItem { + var items []ui.ContextMenuItem + if len(a.editorGroups()) < ui.MaxEditorGroups { + items = append(items, + ui.ContextMenuItem{Label: i18n.Text("menu.splitRight"), Command: "editor.split"}, + ui.ContextMenuItem{Label: i18n.Text("menu.splitDown"), Command: "editor.splitDown"}) + } + if len(a.editorGroups()) > 1 { + items = append(items, + ui.ContextMenuItem{Label: i18n.Text("menu.closeSplit"), Command: "editor.closeSplit"}) + } + return items +} diff --git a/internal/app/editor_split.go b/internal/app/editor_split.go new file mode 100644 index 000000000..82b576ff4 --- /dev/null +++ b/internal/app/editor_split.go @@ -0,0 +1,227 @@ +package app + +import ( + "strings" + + "github.com/eugenioenko/ttt/internal/asynctask" + "github.com/eugenioenko/ttt/internal/core/buffer" + "github.com/eugenioenko/ttt/internal/core/fold" + "github.com/eugenioenko/ttt/internal/ui" + "github.com/gdamore/tcell/v3" +) + +func (a *App) editorGroups() []*ui.EditorGroupWidget { + if a.EditorSplit == nil { + return []*ui.EditorGroupWidget{a.EditorGroup} + } + return a.EditorSplit.Groups() +} + +func (a *App) wireEditorGroupActivation(g *ui.EditorGroupWidget) { + g.OnActivate = func() { + a.activateEditorGroup(g) + if !a.Root.HasModalOverlay() { + a.Root.SetFocus(g) + } + } + g.OnEmpty = func() { a.editorGroupEmpty(g) } + g.RelatedGroups = a.editorGroups + g.OpenFileElsewhere = func(path string) bool { + if g.BufferForPath(path) != nil { + return false + } + for _, other := range a.editorGroups() { + if other != g && g.OpenSharedFileFrom(other, path) { + return true + } + } + return false + } +} + +func (a *App) activateEditorGroup(g *ui.EditorGroupWidget) { + if a.EditorGroup == g { + return + } + a.FlushEditorOnChange() + a.CloseAutocomplete() + a.DismissHover() + a.DismissSignatureHelp() + a.Root.ClearEditorLinkHover() + if a.EditorGroup.Editor.Undo != nil { + a.EditorGroup.Editor.Undo.BreakGroup() + } + a.EditorGroup = g + if g.OnActiveTabChange != nil { + g.OnActiveTabChange() + } +} + +func (a *App) SplitEditor() { a.splitEditor(ui.SplitRight) } +func (a *App) SplitEditorDown() { a.splitEditor(ui.SplitBottom) } + +func (a *App) splitEditor(position ui.SplitPosition) { + if a.Root.HasOverlay() || len(a.editorGroups()) >= ui.MaxEditorGroups { + return + } + a.FlushEditorOnChange() + left := a.EditorGroup + right := a.newEditorGroupLike(left) + a.EditorSplit.SplitGroup(left, right, position) + a.activateEditorGroup(right) + a.FocusEditor() +} + +func (a *App) CloseEditorSplit() { a.removeEditorGroup(a.EditorGroup, true) } + +func (a *App) editorGroupEmpty(group *ui.EditorGroupWidget) { + if len(a.editorGroups()) > 1 { + a.removeEditorGroup(group, false) + return + } + if a.welcomeWhenEmpty && len(a.Workspace.Paths()) == 0 { + a.ShowEmptyState() + } +} + +func (a *App) removeEditorGroup(closing *ui.EditorGroupWidget, merge bool) { + if a.Root.HasOverlay() || a.EditorSplit == nil || len(a.editorGroups()) < 2 { + return + } + a.FlushEditorOnChange() + a.Root.CancelPointerCapture() + groups := a.editorGroups() + var keep *ui.EditorGroupWidget + for i, group := range groups { + if group == closing { + keep = groups[(i+len(groups)-1)%len(groups)] + break + } + } + a.activateEditorGroup(keep) + closing.OnEmpty = nil + if merge { + keep.DiscardDuplicateViews(closing) + keep.MergeTabsFrom(closing) + } + a.EditorSplit.RemoveGroup(closing) + closing.OnEmpty, closing.OnActivate, closing.OpenFileElsewhere = nil, nil, nil + closing.CloseAllTabs() + if closing.FoldTask != nil { + a.releaseTask(closing.FoldTask) + } + if closing.FoldDelay != nil { + a.releaseTask(closing.FoldDelay) + } + a.FocusEditor() +} + +func (a *App) focusEditorAfterClick() { + if !a.Root.HasModalOverlay() && !a.Root.FocusClaimed() { + a.FocusEditor() + } +} + +func (a *App) EqualizeEditorGroups() { a.EditorSplit.Equalize() } +func (a *App) FocusEditorLeft() { a.focusEditorDirection(-1, 0) } +func (a *App) FocusEditorRight() { a.focusEditorDirection(1, 0) } +func (a *App) FocusEditorUp() { a.focusEditorDirection(0, -1) } +func (a *App) FocusEditorDown() { a.focusEditorDirection(0, 1) } +func (a *App) focusEditorDirection(dx, dy int) { + if a.Root.HasOverlay() { + return + } + if next := a.EditorSplit.Neighbor(a.EditorGroup, dx, dy); next != nil { + a.Root.SetFocus(next) + } +} + +func (a *App) newEditorGroupLike(left *ui.EditorGroupWidget) *ui.EditorGroupWidget { + right := newEditorGroup(a.Settings, a.Borders, left.BracketColorStyles) + right.FoldTask = NewTask[[]fold.Range](a, asynctask.TaskOptions{}) + right.FoldDelay = NewTask[struct{}](a, asynctask.TaskOptions{Delay: ui.FoldRecomputeDelay}) + right.InheritDocumentSources(left) + right.ControlModifiers = left.ControlModifiers + right.SetChevrons(left.ChevronCollapsed, left.ChevronExpanded) + right.SetImageCellSize(a.imageCellW, a.imageCellH) + right.SetImageRedraw(left.ImageRedraw) + right.TabBar.OnTabClose = left.TabBar.OnTabClose + right.TabBar.OnTabUnpin = left.TabBar.OnTabUnpin + right.TabBar.OnTabRightClick = left.TabBar.OnTabRightClick + right.TabBar.MoreButton.OnClick = left.TabBar.MoreButton.OnClick + if left.TabBar.PostDragAutoScrollTick != nil { + a.wireEditorAutoScroll(right) + } + right.Editor.OnChange = left.Editor.OnChange + right.OnError = left.OnError + right.OnNotify = left.OnNotify + right.OnFileOpen = left.OnFileOpen + right.OnFileClose = left.OnFileClose + right.OnContentTabClose = left.OnContentTabClose + right.OnDiagnosticsChanged = left.OnDiagnosticsChanged + right.FileHandler = left.FileHandler + right.OnFileHandled = left.OnFileHandled + right.LinkWorkDir = left.LinkWorkDir + right.OnOpenURL = left.OnOpenURL + right.OnOpenFileLink = left.OnOpenFileLink + right.OnLinkHover = left.OnLinkHover + right.ResolveFileLinks = left.ResolveFileLinks + right.OnBookmarkChanged = left.OnBookmarkChanged + right.OnGutterClick = left.OnGutterClick + right.OnActiveTabChange = a.editorTabChanged + a.wireEditorGroupActivation(right) + right.NameUntitledPlaceholder() + return right +} + +func (a *App) isEditorPathDirty(path string) bool { + for _, group := range a.editorGroups() { + if group.IsDirtyPath(path) { + return true + } + } + return false +} + +func (a *App) openEditorFiles() []ui.OpenDocument { + var files []ui.OpenDocument + seen := map[string]bool{} + for _, group := range a.editorGroups() { + for _, file := range group.OpenFiles() { + if !seen[file.Path] { + seen[file.Path] = true + files = append(files, file) + } + } + } + return files +} + +func (a *App) openEditorDocuments() []ui.OpenDocument { + docs := a.openEditorFiles() + for i := range docs { + docs[i].Text = strings.Join(a.lspLines(docs[i].Path), "\n") + } + return docs +} + +func (a *App) wireEditorAutoScroll(group *ui.EditorGroupWidget) { + group.TabBar.PostDragAutoScrollTick = func(generation uint64) { + a.Screen.PostEvent(tcell.NewEventInterrupt(&ui.TabDragAutoScrollTick{Target: group.TabBar, Generation: generation})) + } + group.Editor.PostDragAutoScrollTick = func(generation uint64) { + a.Screen.PostEvent(tcell.NewEventInterrupt(&ui.EditorDragAutoScrollTick{Target: group.Editor, Generation: generation})) + } +} + +func (a *App) editorBufferForPath(path string) *buffer.Buffer { + if buf := a.EditorGroup.BufferForPath(path); buf != nil { + return buf + } + for _, group := range a.editorGroups() { + if buf := group.BufferForPath(path); buf != nil { + return buf + } + } + return nil +} diff --git a/internal/app/editor_split_async_test.go b/internal/app/editor_split_async_test.go new file mode 100644 index 000000000..e870db293 --- /dev/null +++ b/internal/app/editor_split_async_test.go @@ -0,0 +1,99 @@ +package app + +import ( + "strings" + "testing" + + "github.com/eugenioenko/ttt/internal/config" + "github.com/eugenioenko/ttt/internal/core/diff" + "github.com/eugenioenko/ttt/internal/ui" +) + +func splitResultApp() (*App, *ui.EditorGroupWidget, *ui.EditorGroupWidget) { + owner := ui.NewEditorGroupWidget(nil, 4, true, "relative") + other := ui.NewEditorGroupWidget(nil, 4, true, "relative") + layout := ui.NewEditorLayoutWidget(owner, nil) + layout.SplitGroup(owner, other, ui.SplitRight) + return &App{EditorGroup: other, EditorSplit: layout}, owner, other +} + +func TestSplitDiffContentFollowsOwningTab(t *testing.T) { + for _, action := range []string{"switch", "move", "reopen"} { + t.Run(action, func(t *testing.T) { + a, owner, other := splitResultApp() + owner.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) + target := owner.ActiveDiffWidget() + target.Loading = true + if action == "move" { + owner.MoveActiveTabTo(other) + a.EditorGroup = owner + } else { + other.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) + other.ActiveDiffWidget().Loading = true + } + if action == "reopen" { + owner.CloseTab() + owner.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) + owner.ActiveDiffWidget().Loading = true + } + a.applyDiffContent(&DiffContentResult{Widget: target, TabName: "file.txt (diff)", OldLines: []string{"old"}, NewLines: []string{"new"}}) + if action == "reopen" { + if !owner.ActiveDiffWidget().Loading { + t.Fatal("closed-tab result completed a replacement tab") + } + } else if target.Loading { + t.Fatal("owning tab remained loading after group navigation") + } + if action != "move" && !other.ActiveDiffWidget().Loading { + t.Fatal("result completed a different group's same-name tab") + } + }) + } +} + +func TestSplitCommitResultsFollowOwningIncarnation(t *testing.T) { + for _, moved := range []bool{false, true} { + t.Run(map[bool]string{false: "switch", true: "move"}[moved], func(t *testing.T) { + a, owner, other := splitResultApp() + ref := strings.Repeat("a", 40) + tabID := commitDetailTabID("/repo", ref) + detail := ui.NewCommitDetailWidget("/repo", ref, "aaaaaaa", false) + detail.Incarnation = 1 + owner.OpenPluginTab(tabID, "Commit", detail) + if moved { + owner.MoveActiveTabTo(other) + a.EditorGroup = owner + } + distractor := ui.NewCommitDetailWidget("/repo", ref, "aaaaaaa", false) + distractor.Incarnation = 2 + a.EditorGroup.OpenPluginTab(tabID, "Commit", distractor) + a.ApplyCommitDetail(&CommitDetailResult{Incarnation: 1, Dir: "/repo", Ref: ref, Message: "loaded", Files: []ui.CommitDetailFile{{Path: "file.txt"}}}) + if detail.Message != "loaded" || distractor.Message != "" { + t.Fatal("commit result did not reach only its owning incarnation") + } + a.ApplyCommitDetailContext(&CommitDetailContextResult{ + Incarnation: 1, TabID: tabID, Dir: "/repo", Ref: ref, FileIndex: 0, + FileKey: ui.CommitDetailContextKey(detail.Files[0]), OldLines: []string{"old"}, NewLines: []string{"new"}, + }) + if detail.Files[0].FullFileState != ui.CommitDetailFullFileLoaded { + t.Fatal("context result did not reach its owning incarnation") + } + }) + } +} + +func TestOpenCommitDetailReusesInactiveGroupRequest(t *testing.T) { + config.OverrideConfigDir = t.TempDir() + t.Cleanup(func() { config.OverrideConfigDir = "" }) + a := buildTestApp(t, config.DefaultSettings()) + t.Cleanup(a.CloseTasks) + owner := a.EditorGroup + ref := strings.Repeat("a", 40) + detail := ui.NewCommitDetailWidget("/repo", ref, "aaaaaaa", false) + owner.OpenPluginTab(commitDetailTabID("/repo", ref), "Commit", detail) + a.SplitEditor() + a.OpenCommitDetail("/repo", ref, "aaaaaaa") + if a.EditorGroup != owner || a.EditorGroup.CommitDetailWidgetByTab(commitDetailTabID("/repo", ref)) != detail || len(a.commitDetailRequests) != 0 { + t.Fatal("opening an existing commit started another group's request") + } +} diff --git a/internal/app/editor_split_lifecycle_test.go b/internal/app/editor_split_lifecycle_test.go new file mode 100644 index 000000000..9a3d7653d --- /dev/null +++ b/internal/app/editor_split_lifecycle_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "github.com/eugenioenko/ttt/internal/lsp" + "github.com/eugenioenko/ttt/internal/ui" + "os" + "path/filepath" + "testing" + + "github.com/eugenioenko/ttt/internal/config" +) + +func TestSplitGroupOwnsAndReleasesFoldTasks(t *testing.T) { + config.OverrideConfigDir = t.TempDir() + t.Cleanup(func() { config.OverrideConfigDir = "" }) + a := buildTestApp(t, config.DefaultSettings()) + t.Cleanup(a.CloseTasks) + count := len(a.tasks.tasks) + a.SplitEditor() + if a.EditorGroup.FoldTask == nil || a.EditorGroup.FoldDelay == nil || len(a.tasks.tasks) != count+2 { + t.Fatal("split group did not register its own fold tasks") + } + a.CloseEditorSplit() + if len(a.tasks.tasks) != count { + t.Fatal("closing the group leaked its fold tasks") + } +} + +func TestSplitRetainsInactiveDocumentSnapshotsAndDiagnostics(t *testing.T) { + config.OverrideConfigDir = t.TempDir() + t.Cleanup(func() { config.OverrideConfigDir = "" }) + a := buildTestApp(t, config.DefaultSettings()) + t.Cleanup(a.CloseTasks) + path := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(path, []byte("text"), 0600); err != nil { + t.Fatal(err) + } + owner := a.EditorGroup + if err := owner.OpenFile(path); err != nil { + t.Fatal(err) + } + owner.SetDiagnosticsSource("plugin:test", path, []ui.Diagnostic{{Message: "existing"}}) + original := owner.ActiveBuffer() + a.SplitEditor() + peer := a.EditorGroup + if got := a.snapshotCodeActionBuffers()[path]; got.Buffer != original || got.Version != original.Version() { + t.Fatal("inactive document missing from code-action snapshot") + } + if err := peer.OpenFile(path); err != nil { + t.Fatal(err) + } + if got := peer.DiagnosticsByPath()[path]; len(got) != 1 || got[0].Message != "existing" { + t.Fatal("new split lost existing diagnostics") + } + a.DocVersions[path] = 3 + key := pullKey{path: path, version: 3, epoch: a.lspEpochs["test"]} + if !a.setPulledDiagnostics("test", key, pulledDiagnostics{replace: true, diags: []lsp.Diagnostic{{Message: "pulled"}}}) { + t.Fatal("diagnostics not applied") + } + for _, group := range a.editorGroups() { + if len(group.DiagnosticsByPath()[path]) != 2 { + t.Fatal("diagnostics missing from a document view") + } + } + a.ClearPluginSources("test") + for _, group := range a.editorGroups() { + if got := group.DiagnosticsByPath()[path]; len(got) != 1 || got[0].Message != "pulled" { + t.Fatal("plugin diagnostics remained in a document view") + } + } +} diff --git a/internal/app/eventloop.go b/internal/app/eventloop.go index fcf8c071a..54c66c91c 100644 --- a/internal/app/eventloop.go +++ b/internal/app/eventloop.go @@ -291,9 +291,17 @@ func RunEventLoop( closeTerminal(v) } case *ui.TabDragAutoScrollTick: - app.EditorGroup.TabBar.HandleDragAutoScrollTick(v.Generation) + target := v.Target + if target == nil { + target = app.EditorGroup.TabBar + } + target.HandleDragAutoScrollTick(v.Generation) case *ui.EditorDragAutoScrollTick: - app.EditorGroup.Editor.HandleDragAutoScrollTick(v.Generation) + target := v.Target + if target == nil { + target = app.EditorGroup.Editor + } + target.HandleDragAutoScrollTick(v.Generation) case *execInputRequest: if v.Lifecycle.claim() { switch event := v.Event.(type) { @@ -381,7 +389,9 @@ func RunEventLoop( if len(v.Diagnostics) > 0 { lines = app.lspLines(v.Path) } - app.EditorGroup.SetDiagnosticsSource(lspDiagnosticsSource(v.Server), v.Path, LspToUIDiagnostics(v.Diagnostics, lines)) + for _, group := range app.editorGroups() { + group.SetDiagnosticsSource(lspDiagnosticsSource(v.Server), v.Path, LspToUIDiagnostics(v.Diagnostics, lines)) + } case *LSPDiagnosticRefresh: app.handleDiagnosticRefresh(v.Server) case *LSPServerStopped: @@ -427,18 +437,7 @@ func RunEventLoop( case *ui.SearchBatch: app.Search.ApplyBatch(v) case *DiffContentResult: - if v.Err != nil { - app.StatusError(i18n.Format("status.failedToFetchFileContent", i18n.Args{"error": v.Err.Error()})) - if dv := app.EditorGroup.DiffWidgetByTab(v.TabName); dv != nil { - dv.FailLoading() - } - } else { - if dv := app.EditorGroup.DiffWidgetByTab(v.TabName); dv != nil { - dv.SetOldLines(v.OldLines) - dv.SetNewLines(v.NewLines) - dv.FinishLoading() - } - } + app.applyDiffContent(v) case *plugin.PluginAsyncResult: if v.Callback != nil { v.Callback() diff --git a/internal/app/file_handlers.go b/internal/app/file_handlers.go index 4d7cf3747..892019103 100644 --- a/internal/app/file_handlers.go +++ b/internal/app/file_handlers.go @@ -49,11 +49,15 @@ func (a *App) SyncFileHandlerTabs() { if owner.Loaded() && owner.Enabled { continue } - a.EditorGroup.ShowBinaryCard(path) + for _, group := range a.editorGroups() { + group.ShowBinaryCard(path) + } a.closeFileHandlerTab(path) released = true } if released { - a.EditorGroup.RetryFileHandlers() + for _, group := range a.editorGroups() { + group.RetryFileHandlers() + } } } diff --git a/internal/app/fileops.go b/internal/app/fileops.go index 042b25b9e..c7535b158 100644 --- a/internal/app/fileops.go +++ b/internal/app/fileops.go @@ -84,7 +84,9 @@ func (a *App) FileOpRename(path string, reload func()) { } a.invalidateRepositoryPath(path, RepositoryWorktree) a.invalidateRepositoryPath(newPath, RepositoryWorktree) - a.EditorGroup.RenamePath(path, newPath) + for _, group := range a.editorGroups() { + group.RenamePath(path, newPath) + } reload() }) } diff --git a/internal/app/gitgutter.go b/internal/app/gitgutter.go index 753ac66d1..12bbe9b5e 100644 --- a/internal/app/gitgutter.go +++ b/internal/app/gitgutter.go @@ -59,7 +59,7 @@ func (a *App) RequestGitGutter(filePath string, bufferLines []string) { copy(linesCopy, bufferLines) showTrailing := false var version uint64 - if buf := a.EditorGroup.BufferForPath(filePath); buf != nil { + if buf := a.editorBufferForPath(filePath); buf != nil { showTrailing = buf.ShowTrailingNewline version = buf.Version() } @@ -106,18 +106,28 @@ func (a *App) RequestGitGutter(filePath string, bufferLines []string) { } func (a *App) applyGitGutterResult(v *gitGutterResult) { - a.EditorGroup.SetLineChanges(v.Path, v.Changes) + for _, group := range a.editorGroups() { + group.SetLineChanges(v.Path, v.Changes) + } switch { case v.DiffOn && v.Conflict: a.closeConflictedInlineDiff(v) case v.DiffOn: - a.EditorGroup.SetInlineDiff(v.Path, v.Base, v.Diff, v.Version, v.Lines) + for _, group := range a.editorGroups() { + group.SetInlineDiff(v.Path, v.Base, v.Diff, v.Version, v.Lines) + } } } func (a *App) closeConflictedInlineDiff(v *gitGutterResult) { - if a.EditorGroup.IsInlineDiffPath(v.Path) { - a.EditorGroup.DisableInlineDiff(v.Path) + closed := false + for _, group := range a.editorGroups() { + if group.IsInlineDiffPath(v.Path) { + group.DisableInlineDiff(v.Path) + closed = true + } + } + if closed { a.StatusNotify(i18n.Format("status.mergeConflictDiffClosed", i18n.Args{"path": v.RelPath})) } } diff --git a/internal/app/grammars.go b/internal/app/grammars.go index 2c3cc6442..b2cfb51be 100644 --- a/internal/app/grammars.go +++ b/internal/app/grammars.go @@ -55,7 +55,9 @@ func (a *App) applyGrammarSettings(list []config.GrammarSetting) { func (a *App) reloadGrammars(list []config.GrammarSetting) { a.loadedGrammars = slices.Clone(list) a.reportGrammarErrors(loadGrammarSettings(list)) - a.EditorGroup.RefreshHighlighters() + for _, group := range a.editorGroups() { + group.RefreshHighlighters() + } } // installPluginGrammars copies the plugin's grammars into the config grammars diff --git a/internal/app/images.go b/internal/app/images.go index d523bc87e..253588d3b 100644 --- a/internal/app/images.go +++ b/internal/app/images.go @@ -28,7 +28,9 @@ func (a *App) RefreshImageCellSize() { } a.imageCellW, a.imageCellH = w, h if a.EditorGroup != nil { - a.EditorGroup.SetImageCellSize(w, h) + for _, group := range a.editorGroups() { + group.SetImageCellSize(w, h) + } } } diff --git a/internal/app/large_files.go b/internal/app/large_files.go index 1fb140209..0d7d710f1 100644 --- a/internal/app/large_files.go +++ b/internal/app/large_files.go @@ -14,7 +14,7 @@ type largeFileState struct { func (a *App) SyncReloadSegment() { for path := range a.largeFiles.stale { - buf := a.EditorGroup.BufferForPath(path) + buf := a.editorBufferForPath(path) if buf == nil || !buf.DiskChanged(path) { delete(a.largeFiles.stale, path) } diff --git a/internal/app/lsp_positions.go b/internal/app/lsp_positions.go index 013bd447f..e8f3e6352 100644 --- a/internal/app/lsp_positions.go +++ b/internal/app/lsp_positions.go @@ -10,8 +10,10 @@ import ( // Call on the UI thread; async requests keep this immutable text snapshot. func (a *App) lspLines(path string) []string { - if buf := a.EditorGroup.BufferForPath(path); buf != nil { - return slices.Clone(buf.Lines) + for _, group := range a.editorGroups() { + if buf := group.BufferForPath(path); buf != nil { + return slices.Clone(buf.Lines) + } } text, err := os.ReadFile(path) if err != nil { @@ -21,7 +23,7 @@ func (a *App) lspLines(path string) []string { } func (a *App) lspColumn(path string, line, col int) int { - if buf := a.EditorGroup.BufferForPath(path); buf != nil { + if buf := a.editorBufferForPath(path); buf != nil { if line < 0 || line >= len(buf.Lines) { return 0 } diff --git a/internal/app/lsp_pull.go b/internal/app/lsp_pull.go index 05a9481f7..be679a5bc 100644 --- a/internal/app/lsp_pull.go +++ b/internal/app/lsp_pull.go @@ -108,7 +108,7 @@ func (a *App) cancelBackgroundPull(path, lang string) { } func (a *App) handleDiagnosticRefresh(server string) { - for _, d := range a.EditorGroup.OpenFiles() { + for _, d := range a.openEditorFiles() { if key, _, ok := a.lspResolve(d.Path, d.Language); ok && key == server { delete(a.lastPulled, d.Path) } @@ -135,7 +135,7 @@ func (a *App) pullOpenDocuments(server, exclude string, immediate bool) { workDir := "" a.DocVersionsMu.Lock() epoch := a.lspEpochs[server] - for _, d := range a.EditorGroup.OpenFiles() { + for _, d := range a.openEditorFiles() { if d.Path == exclude { continue } @@ -246,6 +246,8 @@ func (a *App) setPulledDiagnostics(server string, key pullKey, res pulledDiagnos return false } a.lastPulled[key.path] = key - a.EditorGroup.SetDiagnosticsSource(lspPullDiagnosticsSource(server), key.path, LspToUIDiagnostics(res.diags, a.lspLines(key.path))) + for _, group := range a.editorGroups() { + group.SetDiagnosticsSource(lspPullDiagnosticsSource(server), key.path, LspToUIDiagnostics(res.diags, a.lspLines(key.path))) + } return true } diff --git a/internal/app/lsp_restart.go b/internal/app/lsp_restart.go index 838ca7d68..01af78a24 100644 --- a/internal/app/lsp_restart.go +++ b/internal/app/lsp_restart.go @@ -51,8 +51,10 @@ func (a *App) RestartLSPServer() { // posted is already queued ahead of this event and is cleared here rather than // landing after the restart. func (a *App) handleLSPServerStopped(ev *LSPServerStopped) { - a.EditorGroup.ClearDiagnosticsSource(lspDiagnosticsSource(ev.Server)) - a.EditorGroup.ClearDiagnosticsSource(lspPullDiagnosticsSource(ev.Server)) + for _, group := range a.editorGroups() { + group.ClearDiagnosticsSource(lspDiagnosticsSource(ev.Server)) + group.ClearDiagnosticsSource(lspPullDiagnosticsSource(ev.Server)) + } type openDoc struct { uri, langID, text string inFlight <-chan struct{} @@ -60,7 +62,7 @@ func (a *App) handleLSPServerStopped(ev *LSPServerStopped) { var docs []openDoc sent := make(chan struct{}) a.DocVersionsMu.Lock() - for _, d := range a.EditorGroup.OpenDocuments() { + for _, d := range a.openEditorDocuments() { key, langID, ok := a.lspResolve(d.Path, d.Language) if !ok || key != ev.Server { continue diff --git a/internal/app/pr.go b/internal/app/pr.go index 728e1031b..60dc3a2d2 100644 --- a/internal/app/pr.go +++ b/internal/app/pr.go @@ -17,12 +17,31 @@ type PrFetchResult struct { } type DiffContentResult struct { + Widget *ui.DiffEditorWidget TabName string OldLines []string NewLines []string Err error } +func (a *App) applyDiffContent(result *DiffContentResult) { + for _, group := range a.editorGroups() { + dv := group.DiffWidgetByTab(result.TabName) + if dv == nil || dv != result.Widget { + continue + } + if result.Err != nil { + dv.FailLoading() + a.StatusError(i18n.Format("status.failedToFetchFileContent", i18n.Args{"error": result.Err.Error()})) + } else { + dv.SetOldLines(result.OldLines) + dv.SetNewLines(result.NewLines) + dv.FinishLoading() + } + return + } +} + func (a *App) FetchAndOpenPR(url string) { owner, repo, number, err := github.ParsePRURL(url) if err != nil { diff --git a/internal/app/save_as_lsp_test.go b/internal/app/save_as_lsp_test.go index ed6102361..696065533 100644 --- a/internal/app/save_as_lsp_test.go +++ b/internal/app/save_as_lsp_test.go @@ -15,66 +15,84 @@ import ( ) func TestSaveAsSynchronizesOpenDestinationBeforeSaving(t *testing.T) { - logPath := filepath.Join(t.TempDir(), "notifications.jsonl") - t.Setenv(fakePullSyncLogEnv, logPath) - a, paths := fakePullServersApp(t, false, "target.go", "source.go") - t.Cleanup(func() { a.CloseTasks(); a.LspManager.Shutdown() }) - a.EditorGroup.Editor.ExecCommand(&undo.InsertRuneCommand{Line: 0, Col: 0, Rune: 'X'}) - wantText := strings.Join(a.EditorGroup.ActiveBuffer().Lines, "\n") - document := paths[0] - link := filepath.Join(t.TempDir(), "linked") - if err := os.Symlink(filepath.Dir(document), link); err != nil { - t.Skipf("directory symlinks unavailable: %v", err) - } - savePath := filepath.Join(link, filepath.Base(document)) - if err := os.Remove(document); err != nil { - t.Fatal(err) - } - a.saveFileAs(savePath, nil) - a.DocVersionsMu.Lock() - sent := a.lspSent[document] - a.DocVersionsMu.Unlock() - select { - case <-sent: - case <-time.After(5 * time.Second): - t.Fatal("save notification was not sent") - } - client, err := a.LspManager.ClientForLanguage("go", a.lspWorkDir(document)) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if _, _, err := client.Diagnostic(ctx, FileURI(document)); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(logPath) - if err != nil { - t.Fatal(err) - } - var methods []string - for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { - var message struct { - Method string - Params struct { - TextDocument lsp.TextDocumentIdentifier - ContentChanges []lsp.TextDocumentContentChangeEvent - } - } - if err := json.Unmarshal([]byte(line), &message); err != nil { - t.Fatal(err) - } - params := message.Params - if params.TextDocument.URI != FileURI(document) || message.Method == "textDocument/didOpen" { - continue + for _, peer := range []bool{false, true} { + name := "local destination" + if peer { + name = "peer destination" } - methods = append(methods, message.Method) - if message.Method == "textDocument/didChange" && (len(params.ContentChanges) != 1 || params.ContentChanges[0].Text != wantText) { - t.Fatalf("change did not contain the replacement text: %+v", params.ContentChanges) - } - } - want := []string{"textDocument/didChange", "textDocument/didSave"} - if !slices.Equal(methods, want) { - t.Fatalf("destination notifications = %v, want %v", methods, want) + t.Run(name, func(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "notifications.jsonl") + t.Setenv(fakePullSyncLogEnv, logPath) + a, paths := fakePullServersApp(t, false, "target.go", "source.go") + t.Cleanup(func() { a.CloseTasks(); a.LspManager.Shutdown() }) + if peer { + a.SplitEditor() + if err := a.EditorGroup.OpenFile(paths[1]); err != nil { + t.Fatal(err) + } + } + a.EditorGroup.Editor.ExecCommand(&undo.InsertRuneCommand{Line: 0, Col: 0, Rune: 'X'}) + wantText := strings.Join(a.EditorGroup.ActiveBuffer().Lines, "\n") + document := paths[0] + savePath := document + if !peer { + link := filepath.Join(t.TempDir(), "linked") + if err := os.Symlink(filepath.Dir(document), link); err != nil { + t.Skipf("directory symlinks unavailable: %v", err) + } + savePath = filepath.Join(link, filepath.Base(document)) + } + if err := os.Remove(document); err != nil { + t.Fatal(err) + } + a.saveFileAs(savePath, nil) + a.DocVersionsMu.Lock() + sent := a.lspSent[document] + a.DocVersionsMu.Unlock() + select { + case <-sent: + case <-time.After(5 * time.Second): + t.Fatal("save notification was not sent") + } + client, err := a.LspManager.ClientForLanguage("go", a.lspWorkDir(document)) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, _, err := client.Diagnostic(ctx, FileURI(document)); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + var methods []string + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + var message struct { + Method string + Params struct { + TextDocument lsp.TextDocumentIdentifier + ContentChanges []lsp.TextDocumentContentChangeEvent + } + } + if err := json.Unmarshal([]byte(line), &message); err != nil { + t.Fatal(err) + } + params := message.Params + if params.TextDocument.URI != FileURI(document) || message.Method == "textDocument/didOpen" { + continue + } + methods = append(methods, message.Method) + if message.Method == "textDocument/didChange" && (len(params.ContentChanges) != 1 || params.ContentChanges[0].Text != wantText) { + t.Fatalf("change did not contain the replacement text: %+v", params.ContentChanges) + } + } + want := []string{"textDocument/didChange", "textDocument/didSave"} + if !slices.Equal(methods, want) { + t.Fatalf("destination notifications = %v, want %v", methods, want) + } + }) } + } diff --git a/internal/app/settings_view.go b/internal/app/settings_view.go index 3dbb08ef3..88b65ae59 100644 --- a/internal/app/settings_view.go +++ b/internal/app/settings_view.go @@ -228,6 +228,9 @@ func settingsCategories() []settingsCategory { {Label: i18n.Text("settings.borderStyle"), Kind: settingEnum, Options: fixedOptions(borderStyleItems), GetString: func(s *config.Settings) string { return s.Editor.BorderStyle }, SetString: func(s *config.Settings, v string) { s.Editor.BorderStyle = v }}, + {Label: i18n.Text("settings.highlightActiveGroupBorder"), Kind: settingBool, + GetBool: func(s *config.Settings) bool { return s.Appearance.HighlightActiveGroupBorder }, + SetBool: func(s *config.Settings, v bool) { s.Appearance.HighlightActiveGroupBorder = v }}, section(i18n.Text("settings.code")), {Label: i18n.Text("settings.syntaxHighlight"), Kind: settingBool, Restart: true, GetBool: func(s *config.Settings) bool { return s.Editor.IsSyntaxHighlightEnabled() }, diff --git a/internal/app/theme.go b/internal/app/theme.go index 132dc594c..ea3819dd4 100644 --- a/internal/app/theme.go +++ b/internal/app/theme.go @@ -313,6 +313,13 @@ func firstRune(s string, fallback rune) rune { func BuildBorderSet(bc config.BorderChars) term.BorderSet { d := term.SingleBorderSet() + cross := d.Cross + for _, preset := range []term.BorderSet{term.DoubleBorderSet(), term.BoldBorderSet(), term.AsciiBorderSet(), term.NoneBorderSet()} { + if firstRune(bc.Horizontal, d.Horizontal) == preset.Horizontal && firstRune(bc.Vertical, d.Vertical) == preset.Vertical { + cross = preset.Cross + break + } + } return term.BorderSet{ Horizontal: firstRune(bc.Horizontal, d.Horizontal), Vertical: firstRune(bc.Vertical, d.Vertical), @@ -324,6 +331,7 @@ func BuildBorderSet(bc config.BorderChars) term.BorderSet { BottomTee: firstRune(bc.BottomTee, d.BottomTee), LeftTee: firstRune(bc.LeftTee, d.LeftTee), RightTee: firstRune(bc.RightTee, d.RightTee), + Cross: firstRune(bc.Cross, cross), } } diff --git a/internal/app/theme_border_test.go b/internal/app/theme_border_test.go new file mode 100644 index 000000000..2cc1b7ba3 --- /dev/null +++ b/internal/app/theme_border_test.go @@ -0,0 +1,24 @@ +package app + +import ( + "testing" + + "github.com/eugenioenko/ttt/internal/config" + "github.com/eugenioenko/ttt/internal/term" +) + +func TestThemeCrossMatchesLegacyBorderStyle(t *testing.T) { + for _, style := range []string{"sharp", "rounded", "double", "bold", "ascii", "none"} { + t.Run(style, func(t *testing.T) { + preset := term.BorderSetByName(style) + chars := config.BorderChars{Horizontal: string(preset.Horizontal), Vertical: string(preset.Vertical)} + if got := BuildBorderSet(chars).Cross; got != preset.Cross { + t.Fatalf("cross = %q, want %q", got, preset.Cross) + } + chars.Cross = "+" + if got := BuildBorderSet(chars).Cross; got != '+' { + t.Fatalf("explicit cross = %q, want +", got) + } + }) + } +} diff --git a/internal/app/watch.go b/internal/app/watch.go index 0f618359a..4a8f9e648 100644 --- a/internal/app/watch.go +++ b/internal/app/watch.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "github.com/eugenioenko/ttt/internal/core/buffer" "github.com/eugenioenko/ttt/internal/i18n" "github.com/eugenioenko/ttt/internal/watcher" @@ -51,7 +52,11 @@ func (a *App) SyncWatched() { if a.Watcher == nil { return } - a.Watcher.Sync(a.EditorGroup.OpenFilePaths()) + var paths []string + for _, group := range a.editorGroups() { + paths = append(paths, group.OpenFilePaths()...) + } + a.Watcher.Sync(paths) if a.Explorer != nil { a.Watcher.SyncDirs(a.Explorer.WatchedDirs()) } @@ -69,7 +74,12 @@ func (a *App) HandleFileChanged(path string) { func (a *App) handleFileChanged(path string, stat func(string) (os.FileInfo, error)) { a.invalidateRepositoryPath(path, RepositoryWorktree) - buf := a.EditorGroup.BufferForPath(path) + var buf *buffer.Buffer + for _, group := range a.editorGroups() { + if buf = group.BufferForPath(path); buf != nil { + break + } + } if buf == nil { return } @@ -103,7 +113,14 @@ func (a *App) handleFileChanged(path string, stat func(string) (os.FileInfo, err } func (a *App) reloadFile(path string) { - if !a.EditorGroup.ReloadFile(path) { + reloaded := false + for _, group := range a.editorGroups() { + if group.ReloadFile(path) { + reloaded = true + break + } + } + if !reloaded { return } delete(a.largeFiles.stale, path) diff --git a/internal/app/widgets.go b/internal/app/widgets.go index 03cf66ec1..474f5c6b9 100644 --- a/internal/app/widgets.go +++ b/internal/app/widgets.go @@ -243,44 +243,7 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work bracketStyles := ResolveBracketColorStyles(cfg.Theme.Editor.BracketColors) highlight.SetTimeLimit(time.Duration(cfg.Settings.Editor.HighlightTimeLimit) * time.Millisecond) - editorGroup := ui.NewEditorGroupWidget(borders, cfg.Settings.Editor.TabSize, cfg.Settings.Editor.LineNumbers, cfg.Settings.Editor.GutterStyle) - editorGroup.SetFileIconMode(cfg.Settings.Appearance.Icons) - editorGroup.InsertSpaces = cfg.Settings.Editor.InsertSpaces - editorGroup.SetIndentGuides(cfg.Settings.Editor.IndentGuides) - editorGroup.InsertFinalNewline = cfg.Settings.Editor.InsertFinalNewline - editorGroup.PreviewTabs = cfg.Settings.Editor.PreviewTabsEnabled() - editorGroup.ShowTrailingNewline = cfg.Settings.Editor.IsShowTrailingNewlineEnabled() - editorGroup.TrimTrailingWhitespace = cfg.Settings.Editor.TrimTrailingWhitespace - editorGroup.SyntaxHighlight = cfg.Settings.Editor.IsSyntaxHighlightEnabled() - editorGroup.WordWrap = cfg.Settings.Editor.WordWrap - editorGroup.WordWrapColumn = cfg.Settings.Editor.WordWrapColumn - editorGroup.WordWrapBreak = cfg.Settings.Editor.WordWrapBreak - editorGroup.LanguageOverrides = cfg.Settings.Editor.LanguageOverrides - editorGroup.WordWrapMaxBytes = cfg.Settings.Editor.WordWrapMaxBytes() - editorGroup.DiffMode = configuredDiffMode(cfg.Settings.Editor.DiffMode) - editorGroup.DiffContext = configuredDiffContext(cfg.Settings.Editor.DiffContext) - editorGroup.DiffWordWrap = cfg.Settings.Editor.DiffWordWrap - editorGroup.SetMarkdownMaxWidth(cfg.Settings.Markdown.WrapWidth) - editorGroup.DiffHighContrast = cfg.Settings.Editor.DiffHighContrast - editorGroup.DiffCollapsedEmphasis = cfg.Settings.Editor.DiffCollapsedEmphasis - editorGroup.DiffSigns = cfg.Settings.Editor.DiffSigns - editorGroup.DiffSignsColor = cfg.Settings.Editor.DiffSignsColor - editorGroup.DiffGutterHighlight = cfg.Settings.Editor.DiffGutterHighlight - editorGroup.DiffColorGutterNumbers = cfg.Settings.Editor.DiffColorGutterNumbers - editorGroup.DiffScrollbarGitMarks = cfg.Settings.Editor.DiffScrollbarGitMarks - editorGroup.LegacyScrollbarGlyphs = cfg.Settings.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy - editorGroup.ApplyFileWordWrap() - editorGroup.Editor.AutoDedent = cfg.Settings.Editor.IsAutoDedentEnabled() - editorGroup.Editor.AutoIndent = cfg.Settings.Editor.IsAutoIndentEnabled() - editorGroup.Editor.AutoClosing = cfg.Settings.Editor.AutoClosing - editorGroup.Editor.LegacyScrollbarGlyphs = cfg.Settings.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy - editorGroup.Editor.ScrollbarGitMarks = cfg.Settings.Editor.IsScrollbarGitMarksEnabled() - editorGroup.UndoDeleteCursorStart = cfg.Settings.Editor.UndoDeleteCursorStart - editorGroup.BracketPairColorization = cfg.Settings.Editor.BracketPairColorization - editorGroup.Editor.BracketPairColorization = cfg.Settings.Editor.BracketPairColorization - editorGroup.SetImageProtocol(cfg.Settings.Image.Protocol) - editorGroup.BracketColorStyles = bracketStyles - editorGroup.Editor.BracketColorStyles = bracketStyles + editorGroup := newEditorGroup(&cfg.Settings, borders, bracketStyles) grammarErrs := loadGrammarSettings(cfg.Settings.Editor.Grammars) _, _ = openFileTargets(editorGroup, openFiles) @@ -297,7 +260,8 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work bottomPanel.AddPanel("output", i18n.Text("panel.output"), output) contentSplit := ui.NewContentSplitWidget() - contentSplit.Top = editorGroup + editorSplit := ui.NewEditorLayoutWidget(editorGroup, borders) + contentSplit.Top = editorSplit contentSplit.Bottom = bottomPanel contentSplit.Borders = borders contentSplit.ShowBottom = false @@ -374,7 +338,6 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work } splitPanel.ShowLeft = sidebar.Visible splitPanel.RightBorderStartY = 2 - contentSplit.RightBorderStartY = &splitPanel.RightBorderStartY editorGroup.TabBar.PointerInteractionValid = func() bool { return contentSplit.TopContentHeight() > 3 } @@ -403,6 +366,7 @@ func BuildAppFromConfig(cfg *config.AppConfig, borders *term.BorderSet, ws *work Root: root, RootBox: rootBox, EditorGroup: editorGroup, + EditorSplit: editorSplit, Sidebar: sidebar, SplitPanel: splitPanel, ContentSplit: contentSplit, @@ -508,3 +472,46 @@ func openFileTargets(g *ui.EditorGroupWidget, targets []FileTarget) ([]FileTarge } return opened, errors.Join(failures...) } + +func newEditorGroup(s *config.Settings, borders *term.BorderSet, bracketStyles []term.Style) *ui.EditorGroupWidget { + editorGroup := ui.NewEditorGroupWidget(borders, s.Editor.TabSize, s.Editor.LineNumbers, s.Editor.GutterStyle) + editorGroup.HighlightActiveBorder = s.Appearance.HighlightActiveGroupBorder + editorGroup.SetFileIconMode(s.Appearance.Icons) + editorGroup.InsertSpaces = s.Editor.InsertSpaces + editorGroup.SetIndentGuides(s.Editor.IndentGuides) + editorGroup.InsertFinalNewline = s.Editor.InsertFinalNewline + editorGroup.PreviewTabs = s.Editor.PreviewTabsEnabled() + editorGroup.ShowTrailingNewline = s.Editor.IsShowTrailingNewlineEnabled() + editorGroup.TrimTrailingWhitespace = s.Editor.TrimTrailingWhitespace + editorGroup.SyntaxHighlight = s.Editor.IsSyntaxHighlightEnabled() + editorGroup.WordWrap = s.Editor.WordWrap + editorGroup.WordWrapColumn = s.Editor.WordWrapColumn + editorGroup.WordWrapBreak = s.Editor.WordWrapBreak + editorGroup.LanguageOverrides = s.Editor.LanguageOverrides + editorGroup.WordWrapMaxBytes = s.Editor.WordWrapMaxBytes() + editorGroup.DiffMode = configuredDiffMode(s.Editor.DiffMode) + editorGroup.DiffContext = configuredDiffContext(s.Editor.DiffContext) + editorGroup.DiffWordWrap = s.Editor.DiffWordWrap + editorGroup.SetMarkdownMaxWidth(s.Markdown.WrapWidth) + editorGroup.DiffHighContrast = s.Editor.DiffHighContrast + editorGroup.DiffCollapsedEmphasis = s.Editor.DiffCollapsedEmphasis + editorGroup.DiffSigns = s.Editor.DiffSigns + editorGroup.DiffSignsColor = s.Editor.DiffSignsColor + editorGroup.DiffGutterHighlight = s.Editor.DiffGutterHighlight + editorGroup.DiffColorGutterNumbers = s.Editor.DiffColorGutterNumbers + editorGroup.DiffScrollbarGitMarks = s.Editor.DiffScrollbarGitMarks + editorGroup.LegacyScrollbarGlyphs = s.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy + editorGroup.ApplyFileWordWrap() + editorGroup.Editor.AutoDedent = s.Editor.IsAutoDedentEnabled() + editorGroup.Editor.AutoIndent = s.Editor.IsAutoIndentEnabled() + editorGroup.Editor.AutoClosing = s.Editor.AutoClosing + editorGroup.Editor.LegacyScrollbarGlyphs = s.Appearance.ScrollbarGlyphs == config.ScrollbarGlyphsLegacy + editorGroup.Editor.ScrollbarGitMarks = s.Editor.IsScrollbarGitMarksEnabled() + editorGroup.UndoDeleteCursorStart = s.Editor.UndoDeleteCursorStart + editorGroup.BracketPairColorization = s.Editor.BracketPairColorization + editorGroup.Editor.BracketPairColorization = s.Editor.BracketPairColorization + editorGroup.SetImageProtocol(s.Image.Protocol) + editorGroup.BracketColorStyles = bracketStyles + editorGroup.Editor.BracketColorStyles = bracketStyles + return editorGroup +} diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 05f6dbb1e..4c17cc5b1 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -366,6 +366,10 @@ func DefaultKeybindings() []KeyBinding { {Key: "f6", Command: "focus.nextGroup"}, {Key: "shift+f6", Command: "focus.prevGroup"}, {Key: "ctrl+f6", Command: "terminal.toggleShortcutForwarding"}, + {Key: "ctrl+k left", Command: "focus.editorLeft"}, + {Key: "ctrl+k right", Command: "focus.editorRight"}, + {Key: "ctrl+k up", Command: "focus.editorUp"}, + {Key: "ctrl+k down", Command: "focus.editorDown"}, {Key: "f10", Command: "menu.file"}, {Key: "alt+f", Command: "menu.file"}, {Key: "alt+e", Command: "menu.edit"}, diff --git a/internal/config/settings.go b/internal/config/settings.go index 4ceef689a..2b513701c 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -359,18 +359,20 @@ type LegacyChevrons struct { } type AppearanceSettings struct { - Icons string `json:"icons"` - Overrides map[string]string `json:"overrides,omitempty"` - Chevrons *LegacyChevrons `json:"chevrons,omitempty"` - ScrollbarGlyphs string `json:"scrollbarGlyphs"` - PointerShapes *bool `json:"pointerShapes,omitempty"` + Icons string `json:"icons"` + Overrides map[string]string `json:"overrides,omitempty"` + Chevrons *LegacyChevrons `json:"chevrons,omitempty"` + ScrollbarGlyphs string `json:"scrollbarGlyphs"` + PointerShapes *bool `json:"pointerShapes,omitempty"` + HighlightActiveGroupBorder bool `json:"highlightActiveGroupBorder"` } func DefaultAppearanceSettings() AppearanceSettings { return AppearanceSettings{ - Icons: IconsNone, - ScrollbarGlyphs: ScrollbarGlyphsBlocks, - PointerShapes: boolPtr(true), + Icons: IconsNone, + ScrollbarGlyphs: ScrollbarGlyphsBlocks, + PointerShapes: boolPtr(true), + HighlightActiveGroupBorder: true, } } diff --git a/internal/config/theme.go b/internal/config/theme.go index cf996ac05..0c1635ec7 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -26,6 +26,7 @@ type BorderChars struct { BottomTee string `json:"bottomTee"` LeftTee string `json:"leftTee"` RightTee string `json:"rightTee"` + Cross string `json:"cross,omitempty"` } type TabStyles struct { diff --git a/internal/i18n/en.json b/internal/i18n/en.json index 5d3e39947..6feaec1e3 100644 --- a/internal/i18n/en.json +++ b/internal/i18n/en.json @@ -1,4 +1,16 @@ { + "settings.highlightActiveGroupBorder": "Highlight active split", + "menu.closeSplit": "Close Split", + "menu.splitDown": "Split Down", + "menu.splitRight": "Split Right", + "command.focusEditorDown": "Split: Focus Down", + "command.focusEditorUp": "Split: Focus Up", + "command.focusEditorRight": "Split: Focus Right", + "command.focusEditorLeft": "Split: Focus Left", + "command.equalizeGroups": "Split: Equalize Sizes", + "command.closeSplit": "Split: Close", + "command.splitEditorDown": "Split: Down", + "command.splitEditor": "Split: Right", "editorGroup.saveDestinationFailed": "Cannot determine save destination {filename}: {error}", "editorGroup.saveDestinationDirty": "Cannot save as {filename}: already open with unsaved changes; save or close that tab first", "plugins.systemDirAbsolute": "System plugin directory must be an absolute path.", diff --git a/internal/term/border.go b/internal/term/border.go index 0952ba8c9..4c27ec672 100644 --- a/internal/term/border.go +++ b/internal/term/border.go @@ -11,6 +11,7 @@ type BorderSet struct { BottomTee rune LeftTee rune RightTee rune + Cross rune } func DoubleBorderSet() BorderSet { @@ -25,6 +26,7 @@ func DoubleBorderSet() BorderSet { BottomTee: '╩', LeftTee: '╠', RightTee: '╣', + Cross: '╬', } } @@ -40,6 +42,7 @@ func SingleBorderSet() BorderSet { BottomTee: '┴', LeftTee: '├', RightTee: '┤', + Cross: '┼', } } @@ -55,6 +58,7 @@ func RoundedBorderSet() BorderSet { BottomTee: '┴', LeftTee: '├', RightTee: '┤', + Cross: '┼', } } @@ -70,6 +74,7 @@ func BoldBorderSet() BorderSet { BottomTee: '┻', LeftTee: '┣', RightTee: '┫', + Cross: '╋', } } @@ -85,6 +90,7 @@ func AsciiBorderSet() BorderSet { BottomTee: '+', LeftTee: '+', RightTee: '+', + Cross: '+', } } @@ -100,6 +106,7 @@ func NoneBorderSet() BorderSet { BottomTee: ' ', LeftTee: ' ', RightTee: ' ', + Cross: ' ', } } diff --git a/internal/ui/content_split.go b/internal/ui/content_split.go index 092b5dbcf..18ac296d0 100644 --- a/internal/ui/content_split.go +++ b/internal/ui/content_split.go @@ -20,6 +20,8 @@ type ContentSplitWidget struct { Top Widget Bottom Widget Position SplitPosition + FullHeightRight bool + HideBottomDivider bool ShowBottom bool BottomH int RightW int @@ -30,9 +32,9 @@ type ContentSplitWidget struct { Borders *term.BorderSet OnResize func(height int) OnResizeEnd func() + DividerVisible func(x, y int) bool OnBottomClick func() OnTopClick func() - RightBorderStartY *int dragging bool wasPressed bool capturedChild Widget @@ -71,6 +73,17 @@ func NewContentSplitWidget() *ContentSplitWidget { func (cs *ContentSplitWidget) Focusable() bool { return false } +func (cs *ContentSplitWidget) TopBorder() (int, bool) { + if cs.ShowBottom && cs.Bottom != nil && cs.Position == SplitRight { + return 0, true + } + offset, corner := 2, true + if provider, ok := cs.Top.(topBorderProvider); ok { + offset, corner = provider.TopBorder() + } + return min(offset, cs.TopContentHeight()), corner +} + func (cs *ContentSplitWidget) constrainedBottomHeight(totalH, requested int) int { if totalH <= 1 { return 0 @@ -114,7 +127,11 @@ func (cs *ContentSplitWidget) BottomRect() Rect { func (cs *ContentSplitWidget) bottomRect(r Rect) Rect { if cs.Position == SplitRight { width := cs.constrainedRightWidth(r.W, cs.requestedRightWidth(r.W)) - return Rect{X: r.X + r.W - width, Y: r.Y + 1, W: width, H: max(r.H-1, 0)} + header := 1 + if cs.FullHeightRight { + header = 0 + } + return Rect{X: r.X + r.W - width, Y: r.Y + header, W: width, H: max(r.H-header, 0)} } height := cs.constrainedBottomHeight(r.H, cs.requestedBottomHeight(r.H)) return Rect{X: r.X, Y: r.Y + r.H - height, W: max(r.W, 0), H: height} @@ -154,9 +171,6 @@ func (cs *ContentSplitWidget) Render(surface Surface) { if cs.capturedChild == cs.Bottom { cs.capturedChild = nil } - if cs.RightBorderStartY != nil { - *cs.RightBorderStartY = 2 - } if cs.Top != nil && w > 0 && h > 0 { cs.Top.SetRect(Rect{X: r.X, Y: r.Y, W: r.W, H: r.H}) cs.Top.Render(surface) @@ -181,9 +195,8 @@ func (cs *ContentSplitWidget) Render(surface Surface) { bottomH := bottomRect.H divY := h - bottomH - 1 topH := divY - - if cs.RightBorderStartY != nil { - *cs.RightBorderStartY = min(topH, 2) + if cs.HideBottomDivider { + topH++ } // Top content @@ -199,8 +212,10 @@ func (cs *ContentSplitWidget) Render(surface Surface) { } // Horizontal divider - for x := 0; x < w; x++ { - surface.SetCell(x, divY, term.Cell{Ch: b.Horizontal, Style: bs}) + if !cs.HideBottomDivider { + for x := 0; x < w; x++ { + surface.SetCell(x, divY, term.Cell{Ch: b.Horizontal, Style: bs}) + } } // Bottom content @@ -223,10 +238,6 @@ func (cs *ContentSplitWidget) renderRight(surface Surface, r Rect, w, h int, b t divX := w - rightW - 1 topW := divX - if cs.RightBorderStartY != nil { - *cs.RightBorderStartY = 0 - } - if cs.Top != nil && topW > 0 { cs.Top.SetRect(Rect{X: r.X, Y: r.Y, W: topW, H: r.H}) cs.Top.Render(surface.Sub(Rect{X: 0, Y: 0, W: topW, H: h})) @@ -237,17 +248,21 @@ func (cs *ContentSplitWidget) renderRight(surface Surface, r Rect, w, h int, b t } } - surface.SetCell(divX, 0, term.Cell{Ch: b.TopLeft, Style: bs}) - for x := divX + 1; x < w; x++ { - surface.SetCell(x, 0, term.Cell{Ch: b.Horizontal, Style: bs}) + startY := 0 + if !cs.FullHeightRight { + surface.SetCell(divX, 0, term.Cell{Ch: b.TopLeft, Style: bs}) + for x := divX + 1; x < w; x++ { + surface.SetCell(x, 0, term.Cell{Ch: b.Horizontal, Style: bs}) + } + startY = 1 } - for y := 1; y < h; y++ { + for y := startY; y < h; y++ { surface.SetCell(divX, y, term.Cell{Ch: b.Vertical, Style: bs}) } - if cs.Bottom != nil && rightW > 0 && h > 1 { + if cs.Bottom != nil && rightW > 0 && bottomRect.H > 0 { cs.Bottom.SetRect(bottomRect) - cs.Bottom.Render(surface.Sub(Rect{X: divX + 1, Y: 1, W: rightW, H: h - 1})) + cs.Bottom.Render(surface.Sub(Rect{X: divX + 1, Y: bottomRect.Y - r.Y, W: rightW, H: bottomRect.H})) if divider, ok := cs.Bottom.(dividerYProvider); ok { y := divider.DividerScreenY() - r.Y if y >= 0 && y < h { @@ -281,7 +296,11 @@ func (cs *ContentSplitWidget) HandleEvent(ev tcell.Event) EventResult { if cs.Position == SplitRight { size = cs.constrainedRightWidth(r.W, r.X+r.W-mx-1) } else { - size = cs.constrainedBottomHeight(r.H, r.Y+r.H-my-1) + offset := 1 + if cs.HideBottomDivider { + offset = 0 + } + size = cs.constrainedBottomHeight(r.H, r.Y+r.H-my-offset) } cs.BottomRatio = 0 if cs.OnResize != nil { @@ -309,9 +328,12 @@ func (cs *ContentSplitWidget) HandleEvent(ev tcell.Event) EventResult { if cs.ShowBottom && cs.Position == SplitRight { divX := cs.BottomRect().X - 1 - if freshClick && mx == divX { - cs.dragging = true - return EventCaptured + if mx == divX { + if freshClick && cs.OverDivider(mx, my) { + cs.dragging = true + return EventCaptured + } + return EventIgnored } if mx > divX && cs.Bottom != nil { @@ -352,7 +374,7 @@ func (cs *ContentSplitWidget) HandleEvent(ev tcell.Event) EventResult { return EventCaptured } - if freshClick && my == divY && mx < r.X+r.W-1 { + if freshClick && my == divY && mx < r.X+r.W-1 && !cs.HideBottomDivider { cs.dragging = true return EventCaptured } @@ -402,6 +424,9 @@ func (cs *ContentSplitWidget) HandleEvent(ev tcell.Event) EventResult { // OverDivider reports whether the pointer is on the panel's divider: a row // when the panel is docked at the bottom, a column when it is docked right. func (cs *ContentSplitWidget) OverDivider(mx, my int) bool { + if cs.DividerVisible != nil && !cs.DividerVisible(mx, my) { + return false + } r := cs.GetRect() if cs.Position == SplitRight { if !cs.ShowBottom || cs.Bottom == nil || my < r.Y || my >= r.Y+r.H { @@ -426,6 +451,9 @@ func (cs *ContentSplitWidget) DividerScreenY() int { if !cs.ShowBottom || cs.Bottom == nil || cs.Position == SplitRight { return -1 } + if cs.HideBottomDivider { + return cs.BottomRect().Y + } return cs.BottomRect().Y - 1 } @@ -437,14 +465,23 @@ func (cs *ContentSplitWidget) DividerScreenX() int { } func (cs *ContentSplitWidget) BorderJunctions() borderJunctions { - junctions := borderJunctions{BottomX: [2]int{-1, -1}, RightY: -1} + junctions := borderJunctions{BottomX: [4]int{-1, -1, -1, -1}, RightY: [4]int{-1, -1, -1, -1}} if !cs.ShowBottom || cs.Bottom == nil { + if provider, ok := cs.Top.(borderJunctionProvider); ok { + return provider.BorderJunctions() + } + if divider, ok := cs.Top.(dividerXProvider); ok { + junctions.BottomX[0] = divider.DividerScreenX() + } return junctions } if cs.Position == SplitRight { junctions.BottomX[0] = cs.DividerScreenX() + if divider, ok := cs.Top.(dividerXProvider); ok { + junctions.BottomX[1] = divider.DividerScreenX() + } if divider, ok := cs.Bottom.(dividerYProvider); ok { - junctions.RightY = divider.DividerScreenY() + junctions.RightY[0] = divider.DividerScreenY() } } if divider, ok := cs.Bottom.(dividerXProvider); ok { @@ -466,7 +503,11 @@ func (cs *ContentSplitWidget) TopContentHeight() int { if !cs.ShowBottom || cs.Bottom == nil || cs.Position == SplitRight { return r.H } - return max(cs.BottomRect().Y-r.Y-1, 0) + height := cs.BottomRect().Y - r.Y + if !cs.HideBottomDivider { + height-- + } + return max(height, 0) } func (cs *ContentSplitWidget) endDrag() { diff --git a/internal/ui/content_split_resize_test.go b/internal/ui/content_split_resize_test.go index 6a310680a..d63fd938c 100644 --- a/internal/ui/content_split_resize_test.go +++ b/internal/ui/content_split_resize_test.go @@ -1,6 +1,35 @@ package ui -import "testing" +import ( + "testing" + + "github.com/gdamore/tcell/v3" +) + +func TestContentSplitBottomDragTracksPointerWithAndWithoutDivider(t *testing.T) { + for _, hidden := range []bool{false, true} { + split := NewContentSplitWidget() + split.Top, split.Bottom = &passThroughWidget{}, &passThroughWidget{} + split.ShowBottom, split.HideBottomDivider = true, hidden + split.BottomH, split.MinTopH, split.MinBottomH = 10, 5, 4 + split.SetRect(Rect{X: 2, Y: 3, W: 30, H: 24}) + split.OnResize = func(height int) { split.BottomH = height } + y := split.DividerScreenY() + if result := split.HandleEvent(tcell.NewEventMouse(4, y, tcell.Button1, 0)); result != EventCaptured { + t.Fatalf("hidden %v: divider press was not captured", hidden) + } + for _, target := range []int{y, y + 3} { + split.HandleEvent(tcell.NewEventMouse(4, target, tcell.Button1, 0)) + if got := split.DividerScreenY(); got != target { + t.Fatalf("hidden %v: divider row %d, want pointer row %d", hidden, got, target) + } + } + split.HandleEvent(tcell.NewEventMouse(4, y+3, tcell.ButtonNone, 0)) + if split.OwnsPointerCapture() { + t.Fatal("divider retained capture after release") + } + } +} func TestContentSplitResizePanelStepsEffectiveSize(t *testing.T) { split := NewContentSplitWidget() diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index b1989ae4b..aff46cf8c 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -157,6 +157,7 @@ type EditorGroupWidget struct { ShowTrailingNewline bool TrimTrailingWhitespace bool UndoDeleteCursorStart bool + HighlightActiveBorder bool Borders *term.BorderSet OnFileOpen func(path, lang string, lines []string) OnFileChange func(path, lang, text string) @@ -169,6 +170,8 @@ type EditorGroupWidget struct { // takes its place. OnEmpty func() OnActiveTabChange func() + OnActivate func() + OpenFileElsewhere func(string) bool // RelatedGroups includes this group and all peers sharing its buffers. RelatedGroups func() []*EditorGroupWidget lastActiveTabID string @@ -293,6 +296,9 @@ func (g *EditorGroupWidget) Focusable() bool { return true } func (g *EditorGroupWidget) SetFocused(focused bool) { g.focused = focused + if focused && g.OnActivate != nil { + g.OnActivate() + } if t := g.activeTab(); t != nil && t.Content != nil { if setter, ok := t.Content.(interface{ SetFocused(bool) }); ok { setter.SetFocused(focused) @@ -493,6 +499,9 @@ func contentFileError(path string, content Widget) error { } func (g *EditorGroupWidget) OpenFile(path string) error { + if g.OpenFileElsewhere != nil && g.OpenFileElsewhere(path) { + return nil + } if isSpecialFile(path) { err := errors.New(i18n.Format("files.cannotOpenSpecialFile", i18n.Args{"path": path})) g.reportError(err.Error()) @@ -751,12 +760,19 @@ func (g *EditorGroupWidget) canReplaceTab(t *editorTab) bool { } func (g *EditorGroupWidget) isUntouchedPlaceholder(t *editorTab) bool { - return t != nil && t.Placeholder && t.Buf != nil && !t.Buf.Dirty && + return t != nil && t.Placeholder && t.Buf != nil && !t.Buf.Dirty && t.Buf.Version() == 0 && len(t.Buf.Lines) <= 1 && (len(t.Buf.Lines) == 0 || t.Buf.Lines[0] == "") } +func (g *EditorGroupWidget) NameUntitledPlaceholder() { + if t := g.activeTab(); g.isUntouchedPlaceholder(t) { + t.FilePath = g.nextUntitledName(t) + g.syncTabs() + } +} + func (g *EditorGroupWidget) NewFile() { - name := g.nextUntitledName() + name := g.nextUntitledName(nil) g.tabs = append(g.tabs, editorTab{ FilePath: name, Buf: &buffer.Buffer{Lines: []string{""}}, @@ -790,16 +806,19 @@ func (g *EditorGroupWidget) NewFileFromText(text string) { g.syncTabs() return } - tab.FilePath = g.nextUntitledName() + tab.FilePath = g.nextUntitledName(nil) g.tabs = append(g.tabs, tab) g.SwitchTab(len(g.tabs) - 1) } -func (g *EditorGroupWidget) nextUntitledName() string { +func (g *EditorGroupWidget) nextUntitledName(exclude *editorTab) string { taken := make(map[string]bool) - for _, t := range g.tabs { - if t.Virtual { - taken[t.FilePath] = true + for _, group := range append([]*EditorGroupWidget{g}, g.relatedGroups()...) { + for i := range group.tabs { + t := &group.tabs[i] + if t != exclude && t.Virtual { + taken[t.FilePath] = true + } } } if !taken["untitled"] { @@ -1088,7 +1107,7 @@ func contentPluginKey(content Widget) string { func (g *EditorGroupWidget) placeholderTab() editorTab { return editorTab{ - FilePath: "untitled", + FilePath: g.nextUntitledName(nil), Buf: &buffer.Buffer{Lines: []string{""}}, Cur: &cursor.Cursor{}, Vp: &view.Viewport{}, @@ -3277,7 +3296,7 @@ func (g *EditorGroupWidget) syncTabs() { dirty = ts.Buf.Dirty } isEmptyState := g.EmptyStateID != "" && ts.FilePath == g.EmptyStateID - closable := !(len(g.tabs) == 1 && (g.isUntouchedPlaceholder(&ts) || isEmptyState)) + closable := len(g.relatedGroups()) > 1 || !(len(g.tabs) == 1 && (g.isUntouchedPlaceholder(&ts) || isEmptyState)) name := ts.FilePath if ts.Title != "" { name = ts.Title @@ -3412,6 +3431,10 @@ func (g *EditorGroupWidget) Render(surface Surface) { g.TabBar.SetRect(Rect{X: r.X, Y: r.Y, W: r.W, H: editorTabBarH}) tabSurface := surface.Sub(Rect{X: 0, Y: 0, W: w, H: editorTabBarH}) + split := len(g.relatedGroups()) > 1 + g.TabBar.ShowGroupFocus = split && g.HighlightActiveBorder + g.TabBar.GroupActive = g.focused + g.Editor.Passive = split && !g.focused g.TabBar.Render(tabSurface) contentH := h - editorTabBarH @@ -3522,6 +3545,12 @@ func (g *EditorGroupWidget) SetPointerCaptureInvalidated(invalidated func()) { } func (g *EditorGroupWidget) HandleEvent(ev tcell.Event) EventResult { + if mouse, ok := ev.(*tcell.EventMouse); ok && mouse.Buttons()&(tcell.Button1|tcell.Button2|tcell.Button3) != 0 { + x, y := mouse.Position() + if g.GetRect().Contains(x, y) && g.OnActivate != nil { + g.OnActivate() + } + } if _, ok := ev.(*tcell.EventMouse); ok && g.TabBar.PointerGestureActive() { return g.TabBar.HandleEvent(ev) } diff --git a/internal/ui/editor_group_shared.go b/internal/ui/editor_group_shared.go index da8aeae04..dda6bda35 100644 --- a/internal/ui/editor_group_shared.go +++ b/internal/ui/editor_group_shared.go @@ -265,3 +265,23 @@ func (g *EditorGroupWidget) deduplicateDocumentViews() { } } } + +func (g *EditorGroupWidget) DiscardDuplicateViews(source *EditorGroupWidget) { + for i := len(source.tabs) - 1; i >= 0; i-- { + for _, keep := range g.tabs { + if keep.Buf != nil && keep.Buf == source.tabs[i].Buf { + source.notifyContentTabClose(source.tabs[i]) + source.tabs = slices.Delete(source.tabs, i, i+1) + if i < source.pinnedCount { + source.pinnedCount-- + } + break + } + } + } + if len(source.tabs) == 0 { + source.tabs = []editorTab{source.placeholderTab()} + } + source.active = min(source.active, len(source.tabs)-1) + source.syncTabs() +} diff --git a/internal/ui/editor_group_split.go b/internal/ui/editor_group_split.go new file mode 100644 index 000000000..85a9a47c3 --- /dev/null +++ b/internal/ui/editor_group_split.go @@ -0,0 +1,100 @@ +package ui + +import ( + "maps" + "slices" +) + +func (g *EditorGroupWidget) MoveActiveTabTo(target *EditorGroupWidget) { + if g == target || g.activeTab() == nil { + return + } + g.InvalidatePointerInteraction() + target.InvalidatePointerInteraction() + g.saveMultiState() + target.saveMultiState() + tab := g.tabs[g.active] + tab.Preview = false + existing := -1 + for i := range target.tabs { + if tab.Buf != nil && target.tabs[i].Buf == tab.Buf { + existing = i + break + } + } + pinned := g.active < g.pinnedCount + if pinned { + g.pinnedCount-- + } + if tab.Content == nil { + if target.bookmarksByPath == nil { + target.bookmarksByPath = make(map[string]map[int]Bookmark) + } + target.bookmarksByPath[tab.FilePath] = g.Editor.Bookmarks + } + if existing >= 0 { + g.notifyContentTabClose(tab) + } + g.tabs = slices.Delete(g.tabs, g.active, g.active+1) + emptied := len(g.tabs) == 0 + if emptied { + g.tabs = []editorTab{g.placeholderTab()} + } + g.active = min(g.active, len(g.tabs)-1) + g.syncTabs() + + if existing >= 0 { + target.SwitchTab(existing) + if emptied { + g.notifyEmpty() + } + return + } + if len(target.tabs) == 1 && target.isUntouchedPlaceholder(target.activeTab()) { + target.tabs = nil + target.pinnedCount = 0 + } + index := len(target.tabs) + if pinned { + index = target.pinnedCount + target.pinnedCount++ + } + target.tabs = slices.Insert(target.tabs, index, tab) + target.active = index + target.syncTabs() + if emptied { + g.notifyEmpty() + } +} + +func (g *EditorGroupWidget) MergeTabsFrom(source *EditorGroupWidget) { + if g == source { + return + } + activeBuf, activeContent := g.activeTab().Buf, g.activeTab().Content + for i := 0; i < source.TabCount(); { + source.SwitchTab(i) + if source.isUntouchedPlaceholder(source.activeTab()) && !source.IsActiveTabPinned() { + i++ + continue + } + source.MoveActiveTabTo(g) + } + for i := range g.tabs { + if g.tabs[i].Buf == activeBuf && g.tabs[i].Content == activeContent { + g.SwitchTab(i) + break + } + } +} + +func (g *EditorGroupWidget) InheritDocumentSources(source *EditorGroupWidget) { + g.diagSources = make(map[string]map[string][]Diagnostic, len(source.diagSources)) + for id, byPath := range source.diagSources { + g.diagSources[id] = maps.Clone(byPath) + } + g.decoSources = make(map[string]map[string]*decoSource, len(source.decoSources)) + for id, byPath := range source.decoSources { + g.decoSources[id] = maps.Clone(byPath) + } +} diff --git a/internal/ui/editor_group_split_test.go b/internal/ui/editor_group_split_test.go new file mode 100644 index 000000000..3db4b62f1 --- /dev/null +++ b/internal/ui/editor_group_split_test.go @@ -0,0 +1,19 @@ +package ui + +import ( + "testing" + + "github.com/gdamore/tcell/v3" +) + +func TestMergeGroupsKeepsEditedTabsAfterAnUntouchedPlaceholder(t *testing.T) { + source := NewEditorGroupWidget(nil, 4, true, "relative") + target := NewEditorGroupWidget(nil, 4, true, "relative") + source.NewFile() + source.Editor.HandleEvent(tcell.NewEventKey(tcell.KeyRune, "x", 0)) + draft := source.ActiveBuffer() + target.MergeTabsFrom(source) + if target.TabCount() != 1 || target.ActiveBuffer() != draft || !draft.Dirty || draft.Lines[0] != "x" { + t.Fatal("merging a group skipped the edited document behind its empty placeholder") + } +} diff --git a/internal/ui/editor_layout.go b/internal/ui/editor_layout.go new file mode 100644 index 000000000..cb2122583 --- /dev/null +++ b/internal/ui/editor_layout.go @@ -0,0 +1,300 @@ +package ui + +import ( + "math" + + "github.com/eugenioenko/ttt/internal/term" + "github.com/eugenioenko/ttt/internal/widgets" + "github.com/gdamore/tcell/v3" +) + +const MaxEditorGroups = 4 + +type EditorLayoutWidget struct { + BaseWidget + Group *EditorGroupWidget + First *EditorLayoutWidget + Second *EditorLayoutWidget + Split *ContentSplitWidget + Borders *term.BorderSet + invalidated func() + dividerGaps []Rect +} + +func NewEditorLayoutWidget(group *EditorGroupWidget, borders *term.BorderSet) *EditorLayoutWidget { + return &EditorLayoutWidget{Group: group, Borders: borders} +} + +func (e *EditorLayoutWidget) child() Widget { + if e.Group != nil { + return e.Group + } + return e.Split +} + +func (e *EditorLayoutWidget) WidgetChildren() []Widget { return []Widget{e.child()} } +func (e *EditorLayoutWidget) Focusable() bool { return false } + +func (e *EditorLayoutWidget) TopBorder() (int, bool) { + if e.Group != nil { + return 2, true + } + return e.First.TopBorder() +} + +func (e *EditorLayoutWidget) Groups() []*EditorGroupWidget { + if e.Group != nil { + return []*EditorGroupWidget{e.Group} + } + return append(e.First.Groups(), e.Second.Groups()...) +} + +func (e *EditorLayoutWidget) leaf(group *EditorGroupWidget) *EditorLayoutWidget { + if e.Group == group { + return e + } + if e.Group != nil { + return nil + } + if found := e.First.leaf(group); found != nil { + return found + } + return e.Second.leaf(group) +} + +func (e *EditorLayoutWidget) SplitGroup(group, next *EditorGroupWidget, position SplitPosition) bool { + leaf := e.leaf(group) + if leaf == nil || len(e.Groups()) >= MaxEditorGroups { + return false + } + e.InvalidatePointerInteraction() + leaf.First = NewEditorLayoutWidget(group, e.Borders) + leaf.Second = NewEditorLayoutWidget(next, e.Borders) + leaf.Group = nil + s := NewContentSplitWidget() + s.Position, s.FullHeightRight, s.ShowBottom = position, true, true + s.HideBottomDivider = true + s.Borders, s.MinRightW, s.MinTopH, s.MinBottomH = e.Borders, 20, 6, 6 + s.Top, s.Bottom = leaf.First, leaf.Second + s.RightW, s.BottomH, s.BottomRatio = 0, 0, 0.5 + s.OnResize = func(size int) { + total := s.GetRect().H + if s.Position == SplitRight { + total = s.GetRect().W + } + s.BottomRatio = float64(size) / float64(max(total, 1)) + s.RightW, s.BottomH = 0, 0 + } + leaf.Split = s + e.SetPointerCaptureInvalidated(e.invalidated) + return true +} + +func (e *EditorLayoutWidget) remove(group *EditorGroupWidget) bool { + if e.Group != nil { + return e.Group == group + } + if e.First.remove(group) { + *e = *e.Second + } else if e.Second.remove(group) { + *e = *e.First + } + return false +} + +func (e *EditorLayoutWidget) RemoveGroup(group *EditorGroupWidget) bool { + if len(e.Groups()) == 1 || e.leaf(group) == nil { + return false + } + e.InvalidatePointerInteraction() + invalidated := e.invalidated + e.remove(group) + e.SetPointerCaptureInvalidated(invalidated) + return true +} + +func (e *EditorLayoutWidget) Equalize() { + var equalize func(*EditorLayoutWidget) + equalize = func(node *EditorLayoutWidget) { + if node.Split != nil { + first, second := node.First.axisUnits(node.Split.Position), node.Second.axisUnits(node.Split.Position) + node.Split.BottomRatio, node.Split.RightW, node.Split.BottomH = float64(second)/float64(first+second), 0, 0 + equalize(node.First) + equalize(node.Second) + } + } + equalize(e) +} + +func (e *EditorLayoutWidget) Neighbor(group *EditorGroupWidget, dx, dy int) *EditorGroupWidget { + r := group.GetRect() + best := math.MaxInt + var target *EditorGroupWidget + for _, other := range e.Groups() { + if other == group { + continue + } + o := other.GetRect() + x, y := (o.X*2+o.W)-(r.X*2+r.W), (o.Y*2+o.H)-(r.Y*2+r.H) + if (dx != 0 && (o.Y >= r.Y+r.H || r.Y >= o.Y+o.H)) || (dy != 0 && (o.X >= r.X+r.W || r.X >= o.X+o.W)) { + continue + } + if dx*x+dy*y <= 0 { + continue + } + primary, secondary := max(x, -x), max(y, -y) + if dy != 0 { + primary, secondary = secondary, primary + } + if score := primary + secondary*4; score < best { + best, target = score, other + } + } + return target +} + +func (e *EditorLayoutWidget) Render(surface Surface) { + child := e.child() + child.SetRect(e.GetRect()) + child.Render(surface) + e.renderDividerGaps(surface) + e.renderJunctions(surface) +} + +func (e *EditorLayoutWidget) HandleEvent(ev tcell.Event) EventResult { + return e.child().HandleEvent(ev) +} +func (e *EditorLayoutWidget) PointerShapeAt(x, y int) string { + return widgets.PointerShapeAt(e.child(), x, y) +} +func (e *EditorLayoutWidget) OwnsPointerCapture() bool { return widgets.OwnsPointerCapture(e.child()) } +func (e *EditorLayoutWidget) CancelPointerCapture() bool { + return widgets.CancelPointerCapture(e.child()) +} +func (e *EditorLayoutWidget) InvalidatePointerInteraction() bool { + return widgets.InvalidatePointerInteraction(e.child()) +} +func (e *EditorLayoutWidget) SetPointerCaptureInvalidated(fn func()) { + e.invalidated = fn + if e.Split != nil { + e.Split.DividerVisible = e.dividerVisibleAt + } + widgets.SetPointerCaptureInvalidated(e.child(), fn) +} + +func (e *EditorLayoutWidget) BorderJunctions() borderJunctions { + junctions := borderJunctions{BottomX: [4]int{-1, -1, -1, -1}, RightY: [4]int{-1, -1, -1, -1}} + r, index, rightIndex := e.GetRect(), 0, 0 + for _, g := range e.Groups() { + gr := g.GetRect() + if gr.X+gr.W == r.X+r.W && gr.Y > r.Y && rightIndex < len(junctions.RightY) && !e.Split.HideBottomDivider { + junctions.RightY[rightIndex] = gr.Y - 1 + rightIndex++ + } + if gr.Y+gr.H == r.Y+r.H && gr.X > r.X && index < len(junctions.BottomX) { + junctions.BottomX[index] = gr.X - 1 + index++ + } + } + return junctions +} + +func (e *EditorLayoutWidget) axisUnits(axis SplitPosition) int { + if e.Group != nil { + return 1 + } + first, second := e.First.axisUnits(axis), e.Second.axisUnits(axis) + if e.Split.Position == axis { + return first + second + } + return max(first, second) +} + +func (e *EditorLayoutWidget) renderJunctions(surface Surface) { + if e.Split == nil { + return + } + b := term.SingleBorderSet() + if e.Borders != nil { + b = *e.Borders + } + if b.Cross == 0 { + b.Cross = b.LeftTee + } + r := e.GetRect() + if e.Split.Position == SplitRight { + tabJoins := map[int]uint8{} + for _, group := range e.Groups() { + if side := e.dividerSide(group); side != 0 { + if y, ok := group.TabBar.baselineY(); ok { + tabJoins[y] |= side + } + } + } + x := e.Split.DividerScreenX() + for y, sides := range tabJoins { + glyph := b.TopTee + if e.dividerVisibleAt(x, y-1) { + glyph = b.Cross + if sides == 1 { + glyph = b.RightTee + } else if sides == 2 { + glyph = b.LeftTee + } + } else if sides == 1 { + glyph = b.TopRight + } else if sides == 2 { + glyph = b.TopLeft + } + surface.SetCell(x-r.X, y-r.Y, term.Cell{Ch: glyph, Style: term.StyleBorder}) + } + } +} + +func (e *EditorLayoutWidget) renderDividerGaps(surface Surface) { + e.dividerGaps = e.dividerGaps[:0] + if e.Split == nil || e.Split.Position != SplitRight { + return + } + r, dividerX := e.GetRect(), e.Split.DividerScreenX() + tabSides := make([]uint8, max(r.H, 0)) + for _, group := range e.Groups() { + side := e.dividerSide(group) + if side == 0 { + continue + } + tabs := group.TabBar.GetRect() + for y := max(tabs.Y, r.Y); y < min(tabs.Y+tabs.H, r.Y+r.H); y++ { + tabSides[y-r.Y] |= side + } + } + for y, sides := range tabSides { + if sides == 3 { + e.dividerGaps = append(e.dividerGaps, Rect{X: dividerX, Y: y + r.Y, W: 1, H: 1}) + surface.SetCell(dividerX-r.X, y, term.Cell{Ch: ' ', Style: term.StyleDefault}) + } + } +} + +func (e *EditorLayoutWidget) dividerSide(group *EditorGroupWidget) uint8 { + r, x := group.GetRect(), e.Split.DividerScreenX() + if r.X+r.W == x { + return 1 + } + if r.X == x+1 { + return 2 + } + return 0 +} + +func (e *EditorLayoutWidget) dividerVisibleAt(x, y int) bool { + if !e.GetRect().Contains(x, y) { + return false + } + for _, gap := range e.dividerGaps { + if gap.Contains(x, y) { + return false + } + } + return true +} diff --git a/internal/ui/editor_layout_test.go b/internal/ui/editor_layout_test.go new file mode 100644 index 000000000..17e37d82f --- /dev/null +++ b/internal/ui/editor_layout_test.go @@ -0,0 +1,77 @@ +package ui + +import ( + "testing" + + "github.com/eugenioenko/ttt/internal/term" +) + +func TestEditorLayoutStackedSideKeepsSharedDivider(t *testing.T) { + for _, borderName := range []string{"rounded", "double", "ascii"} { + for _, stackLeft := range []bool{false, true} { + side := "right" + if stackLeft { + side = "left" + } + t.Run(borderName+"/"+side, func(t *testing.T) { + borders := term.BorderSetByName(borderName) + left := NewEditorGroupWidget(&borders, 4, true, "relative") + right := NewEditorGroupWidget(&borders, 4, true, "relative") + bottom := NewEditorGroupWidget(&borders, 4, true, "relative") + layout := NewEditorLayoutWidget(left, &borders) + layout.SplitGroup(left, right, SplitRight) + stack := layout.Second + top, wantJoin := right, borders.LeftTee + if stackLeft { + stack, top, wantJoin = layout.First, left, borders.RightTee + } + layout.SplitGroup(top, bottom, SplitBottom) + layout.SetRect(Rect{W: 120, H: 40}) + for _, ratio := range []float64{0.5, 0.35} { + stack.Split.BottomRatio = ratio + cells := makeGrid(120, 40) + layout.Render(NewRenderSurface(cells, layout.GetRect())) + x, tabs := layout.Split.DividerScreenX(), bottom.TabBar.GetRect() + for y := tabs.Y; y < tabs.Y+tabs.H; y++ { + want := borders.Vertical + if y == tabs.Y+tabs.H-1 { + want = wantJoin + } + if got := cells[y][x].Ch; got != want { + t.Fatalf("ratio %v: join at (%d, %d) = %q, want %q", ratio, x, y, got, want) + } + } + if cells[0][x].Ch != ' ' || cells[1][x].Ch != ' ' || cells[2][x].Ch != borders.TopTee { + t.Fatal("shared tab rows lost their gap or top T joint") + } + } + }) + } + } +} + +func TestEditorLayoutEqualizesThreeColumns(t *testing.T) { + first := NewEditorGroupWidget(nil, 4, true, "relative") + second := NewEditorGroupWidget(nil, 4, true, "relative") + third := NewEditorGroupWidget(nil, 4, true, "relative") + layout := NewEditorLayoutWidget(first, nil) + layout.SplitGroup(first, second, SplitRight) + layout.SplitGroup(second, third, SplitRight) + layout.Equalize() + layout.SetRect(Rect{W: 122, H: 28}) + layout.Render(NewRenderSurface(makeGrid(122, 28), Rect{W: 122, H: 28})) + for _, group := range layout.Groups() { + if width := group.GetRect().W; width < 39 || width > 41 { + t.Fatalf("equalized column = %d, want about 40", width) + } + } + layout.Split.BottomRatio = 0.7 + layout.SetRect(Rect{W: 202, H: 28}) + layout.Render(NewRenderSurface(makeGrid(202, 28), Rect{W: 202, H: 28})) + if width := first.GetRect().W; width < 59 || width > 61 { + t.Fatalf("resizing window lost divider ratio: first width %d", width) + } + if !layout.RemoveGroup(second) || len(layout.Groups()) != 2 || layout.leaf(second) != nil { + t.Fatal("removing a nested group did not collapse its split") + } +} diff --git a/internal/ui/editor_widget_autoscroll.go b/internal/ui/editor_widget_autoscroll.go index 9a2fcf53e..bb38f08f7 100644 --- a/internal/ui/editor_widget_autoscroll.go +++ b/internal/ui/editor_widget_autoscroll.go @@ -5,6 +5,7 @@ import "time" const editorDragAutoScrollDelay = 50 * time.Millisecond type EditorDragAutoScrollTick struct { + Target *EditorPaneWidget Generation uint64 } diff --git a/internal/ui/split_panel.go b/internal/ui/split_panel.go index 7bd7d6171..73c895382 100644 --- a/internal/ui/split_panel.go +++ b/internal/ui/split_panel.go @@ -15,6 +15,10 @@ const DefaultSidebarWidth = 30 // reopening at an unusably small width after being dragged nearly closed. const MinSidebarWidth = 15 +type topBorderProvider interface { + TopBorder() (offset int, corner bool) +} + type SplitPanelWidget struct { BaseWidget Left Widget @@ -121,11 +125,13 @@ func (s *SplitPanelWidget) Render(surface Surface) { } // Right border — starts at RightBorderStartY to skip tab bar area - rbStart := s.RightBorderStartY - if rbStart == 0 { + rbStart, corner := s.rightBorder() + if corner && rbStart == 0 { + surface.SetCell(divX, 0, term.Cell{Ch: b.TopTee, Style: bs}) surface.SetCell(w-1, 0, term.Cell{Ch: b.TopRight, Style: bs}) rbStart = 1 - } else if rbStart > 0 && rbStart < h-1 { + } else if corner && rbStart > 0 && rbStart < h-1 { + surface.SetCell(divX, rbStart, term.Cell{Ch: b.LeftTee, Style: bs}) surface.SetCell(w-1, rbStart, term.Cell{Ch: b.TopRight, Style: bs}) rbStart++ } @@ -156,12 +162,12 @@ func (s *SplitPanelWidget) renderSinglePanel(surface Surface, w, h int, b term.B } // Side borders — start at RightBorderStartY - borderStart := s.RightBorderStartY - if borderStart == 0 { + borderStart, corner := s.rightBorder() + if corner && borderStart == 0 { surface.SetCell(0, 0, term.Cell{Ch: b.TopLeft, Style: bs}) surface.SetCell(w-1, 0, term.Cell{Ch: b.TopRight, Style: bs}) borderStart = 1 - } else if borderStart > 0 && borderStart < h-1 { + } else if corner && borderStart > 0 && borderStart < h-1 { surface.SetCell(0, borderStart, term.Cell{Ch: b.TopLeft, Style: bs}) surface.SetCell(w-1, borderStart, term.Cell{Ch: b.TopRight, Style: bs}) borderStart++ @@ -173,6 +179,13 @@ func (s *SplitPanelWidget) renderSinglePanel(surface Surface, w, h int, b term.B s.renderRightBorderJunction(surface, r, w, h, b, bs) } +func (s *SplitPanelWidget) rightBorder() (int, bool) { + if provider, ok := s.Right.(topBorderProvider); ok { + return provider.TopBorder() + } + return s.RightBorderStartY, true +} + func (s *SplitPanelWidget) renderBottomJunction(surface Surface, screenX, w, h int, b term.BorderSet, bs term.Style) { if provider, ok := s.Right.(borderJunctionProvider); ok { junctions := provider.BorderJunctions() @@ -190,9 +203,11 @@ func (s *SplitPanelWidget) renderRightBorderJunction(surface Surface, r Rect, w, if !ok { return } - y := provider.BorderJunctions().RightY - r.Y - if y >= 0 && y < h-1 { - surface.SetCell(w-1, y, term.Cell{Ch: b.RightTee, Style: bs}) + for _, screenY := range provider.BorderJunctions().RightY { + y := screenY - r.Y + if y >= 0 && y < h-1 { + surface.SetCell(w-1, y, term.Cell{Ch: b.RightTee, Style: bs}) + } } } diff --git a/internal/ui/tabbar_widget.go b/internal/ui/tabbar_widget.go index dcf03ce5f..c096af265 100644 --- a/internal/ui/tabbar_widget.go +++ b/internal/ui/tabbar_widget.go @@ -50,11 +50,14 @@ type Tab struct { } type TabDragAutoScrollTick struct { + Target *TabBarWidget Generation uint64 } type TabBarWidget struct { BaseWidget + ShowGroupFocus bool + GroupActive bool Icons string Tabs []Tab Borders *term.BorderSet @@ -204,6 +207,9 @@ func (t *TabBarWidget) Render(surface Surface) { b = *t.Borders } bs := term.StyleBorder + if t.ShowGroupFocus && t.GroupActive { + bs = term.StyleBorderActive + } // Compute spans: active tab needs 2 extra cols for │ │ side borders spans := make([]tabSpan, len(t.Tabs)) @@ -758,3 +764,8 @@ func (t *TabBarWidget) HandleDragAutoScrollTick(generation uint64) bool { } return true } + +func (t *TabBarWidget) baselineY() (int, bool) { + r := t.GetRect() + return r.Y + r.H - 1, r.H > 0 +} diff --git a/internal/ui/widget.go b/internal/ui/widget.go index 109c58a33..1cb2e24a3 100644 --- a/internal/ui/widget.go +++ b/internal/ui/widget.go @@ -34,8 +34,8 @@ type dividerYProvider interface { } type borderJunctions struct { - BottomX [2]int - RightY int + BottomX [4]int + RightY [4]int } type borderJunctionProvider interface { diff --git a/tests/e2e/editor_split_test.go b/tests/e2e/editor_split_test.go new file mode 100644 index 000000000..2e43c7850 --- /dev/null +++ b/tests/e2e/editor_split_test.go @@ -0,0 +1,525 @@ +package e2e + +import ( + "os" + "path/filepath" + "testing" + + "github.com/eugenioenko/ttt/internal/app" + "github.com/eugenioenko/ttt/internal/term" + "github.com/eugenioenko/ttt/internal/ui" + "github.com/gdamore/tcell/v3" +) + +func TestEditorSplitUntitledBuffersAreIndependentAndNumbered(t *testing.T) { + h := newTestHarness(t, 160, 44) + defer h.stop() + h.exec("editor.split") + h.exec("editor.splitDown") + h.exec("editor.splitDown") + groups := h.app.EditorSplit.Groups() + if len(groups) != 4 { + t.Fatalf("groups = %d, want 4", len(groups)) + } + for i, want := range []string{"untitled", "untitled-2", "untitled-3", "untitled-4"} { + group := groups[i] + if group.TabCount() != 1 || group.ActiveFilePath() != want { + t.Fatalf("group %d: tabs %d, name %q, want one %q", i, group.TabCount(), group.ActiveFilePath(), want) + } + for _, other := range groups[:i] { + if group.ActiveBuffer() == other.ActiveBuffer() || group.Editor.Undo == other.Editor.Undo { + t.Fatal("new split shared a buffer or undo history") + } + } + } + h.typeText("only here") + for _, group := range groups[:3] { + if group.ActiveBuffer().Lines[0] != "" { + t.Fatal("typing in one untitled changed another group") + } + } + h.app.Root.SetFocus(groups[0]) + h.exec("file.new") + if groups[0].ActiveFilePath() != "untitled-5" { + t.Fatalf("new file reused another group's name: %q", groups[0].ActiveFilePath()) + } +} + +func TestEditorSplitMiddleClickClosesClickedGroupTab(t *testing.T) { + for _, clickLeft := range []bool{false, true} { + t.Run(map[bool]string{false: "right", true: "left"}[clickLeft], func(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + left := h.app.EditorGroup + if err := left.OpenFile(filepath.Join(h.dir, "alpha.txt")); err != nil { + t.Fatal(err) + } + h.exec("editor.split") + right := h.app.EditorGroup + if err := right.OpenFile(filepath.Join(h.dir, "beta.txt")); err != nil { + t.Fatal(err) + } + clicked, keep := right, left + if clickLeft { + clicked, keep = left, right + } + h.app.Root.SetFocus(keep) + h.redraw() + path := keep.ActiveFilePath() + r := clicked.TabBar.GetRect() + h.app.Root.HandleEvent(tcell.NewEventMouse(r.X+4, r.Y+1, tcell.Button3, 0)) + h.app.Root.HandleEvent(tcell.NewEventMouse(r.X+4, r.Y+1, tcell.ButtonNone, 0)) + h.redraw() + if len(h.app.EditorSplit.Groups()) != 1 || h.app.EditorGroup != keep || keep.TabCount() != 1 || keep.ActiveFilePath() != path { + t.Fatal("middle-click closed a tab in the wrong editor group") + } + }) + } +} + +func TestEditorSplitUntitledPlaceholderLifecycle(t *testing.T) { + for _, state := range []string{"untouched", "edited", "undone", "saved"} { + t.Run(state, func(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + h.exec("editor.splitDown") + group := h.app.EditorGroup + buf := group.ActiveBuffer() + if state != "untouched" { + h.typeText("draft") + } + if state == "undone" { + h.exec("editor.undo") + if buf.Dirty || buf.Lines[0] != "" { + t.Fatal("undo did not return the document to its initial clean contents") + } + } + if state == "saved" && !group.SaveAs(filepath.Join(h.dir, "draft.txt")) { + t.Fatal("could not save untitled document") + } + originalPath := group.ActiveFilePath() + path := filepath.Join(h.dir, "alpha.txt") + if err := group.OpenFile(path); err != nil { + t.Fatal(err) + } + if group.ActiveFilePath() != path { + t.Fatal("opened file did not become active") + } + if state == "untouched" { + if group.TabCount() != 1 || group.BufferForPath(originalPath) != nil { + t.Fatal("opening a file kept the untouched placeholder") + } + } else if group.TabCount() != 2 || group.BufferForPath(originalPath) != buf { + t.Fatal("opening a file replaced a document that had been edited") + } + }) + } +} + +func TestEditorSplitMoreMenuAndEditing(t *testing.T) { + for _, closeRight := range []bool{false, true} { + t.Run(map[bool]string{false: "close from left", true: "close from right"}[closeRight], func(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + left := h.app.EditorGroup + alpha, beta := filepath.Join(h.dir, "alpha.txt"), filepath.Join(h.dir, "beta.txt") + if err := left.OpenFile(alpha); err != nil { + t.Fatal(err) + } + left.CommitActiveTab() + if err := left.OpenFile(beta); err != nil { + t.Fatal(err) + } + h.app.FocusEditor() + h.pressRune('R') + buf, stack := left.ActiveBuffer(), left.Editor.Undo + chooseEditorMore(t, h, "Split Right") + right := h.app.EditorGroup + if left == right || len(h.app.EditorSplit.Groups()) < 2 || right.ActiveBuffer() == buf || right.Editor.Undo == stack || right.ActiveBuffer().Lines[0] != "" { + t.Fatal("split did not create an independent empty document") + } + if err := right.OpenFile(beta); err != nil { + t.Fatal(err) + } + if right.TabCount() != 1 || right.ActiveBuffer() != buf || right.Editor.Undo != stack { + t.Fatal("opening the shared document did not replace the empty placeholder") + } + if left.ActiveFilePath() != beta || right.ActiveFilePath() != beta { + t.Fatal("split lost the open files") + } + if left.GetRect().Y != right.GetRect().Y || left.GetRect().H != right.GetRect().H { + t.Fatal("split groups are not aligned") + } + if delta := left.GetRect().W - right.GetRect().W; delta < -1 || delta > 1 { + t.Fatal("split did not start in equal halves") + } + h.exec("editor.undo") + if buf.Lines[0] != "b" { + t.Fatalf("undo after moving tab = %q", buf.Lines[0]) + } + h.pressRune('R') + left.SwitchToTabByPath(alpha) + r := left.Editor.GetRect() + h.click(r.X+left.Editor.GutterWidth(), r.Y) + h.pressRune('L') + if h.app.EditorGroup != left || left.ActiveBuffer().Lines[0] != "La" || right.ActiveBuffer().Lines[0] != "Rb" { + t.Fatal("clicking the left group did not route edits to it") + } + h.exec("focus.nextGroup") + if h.app.EditorGroup != right || h.app.Root.Focused != right { + t.Fatal("focus cycling did not reach the right group") + } + if !closeRight { + h.exec("focus.prevGroup") + } + chooseEditorMore(t, h, "Close Split") + if len(h.app.EditorSplit.Groups()) > 1 || h.app.EditorGroup.TabCount() != 2 { + t.Fatal("closing split did not merge both groups") + } + if h.app.EditorGroup.BufferForPath(alpha).Lines[0] != "La" || h.app.EditorGroup.BufferForPath(beta).Lines[0] != "Rb" { + t.Fatal("closing split lost edits") + } + h.exec("editor.split") + h.exec("editor.closeSplit") + if h.app.EditorGroup.TabCount() != 2 { + t.Fatal("repeated split changed the tab count") + } + }) + } +} + +func TestEditorSplitDivider(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + h.exec("editor.split") + h.redraw() + layout := h.app.EditorSplit + split := layout.Split + x, y := split.DividerScreenX(), split.GetRect().Y+4 + assertEditorDividerSkipsTabs(t, h, x) + tabs := layout.Groups()[0].TabBar.GetRect() + h.app.Root.HandleEvent(tcell.NewEventMouse(x, tabs.Y+1, tcell.Button1, 0)) + if split.OwnsPointerCapture() { + t.Fatal("tab-bar gap captured a resize drag") + } + h.app.Root.HandleEvent(tcell.NewEventMouse(x, y, tcell.Button1, 0)) + h.app.Root.HandleEvent(tcell.NewEventMouse(x, y, tcell.ButtonNone, 0)) + h.redraw() + if split.DividerScreenX() != x { + t.Fatal("dragging the tab-bar gap resized the split") + } + if shape := h.app.Root.PointerShapeAt(x, y); shape != term.PointerEWResize { + t.Fatalf("divider did not show the resize pointer: %q", shape) + } + for _, event := range []*tcell.EventMouse{ + tcell.NewEventMouse(x, y, tcell.Button1, 0), + tcell.NewEventMouse(x+12, y, tcell.Button1, 0), + tcell.NewEventMouse(x+12, y, tcell.ButtonNone, 0), + } { + h.app.Root.HandleEvent(event) + h.redraw() + } + if split.DividerScreenX() != x+12 || split.OwnsPointerCapture() { + t.Fatalf("divider ended at %d, want %d, captured %v", split.DividerScreenX(), x+12, split.OwnsPointerCapture()) + } + assertEditorDividerSkipsTabs(t, h, split.DividerScreenX()) + h.exec("editor.closeSplit") + h.redraw() + if len(layout.Groups()) != 1 || layout.Group.GetRect().W != layout.GetRect().W { + t.Fatal("closing split did not restore the full editor width") + } +} + +func assertEditorDividerSkipsTabs(t *testing.T, h *testHarness, x int) { + t.Helper() + cells, width, _ := h.screen.GetContents() + for _, group := range h.app.EditorSplit.Groups() { + tabs := group.TabBar.GetRect() + for y := tabs.Y; y < tabs.Y+tabs.H; y++ { + want := " " + if y == tabs.Y+tabs.H-1 { + want = string(h.app.Borders.TopTee) + } + if got := cells[y*width+x].Str; got != want { + t.Fatalf("divider at (%d, %d) = %q, want %q", x, y, got, want) + } + if h.app.Root.PointerShapeAt(x, y) == term.PointerEWResize { + t.Fatalf("tab-bar gap showed a resize pointer at (%d, %d)", x, y) + } + } + } +} + +func TestEditorSplitExistingFileAndQuit(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + path := filepath.Join(h.dir, "alpha.txt") + left := h.app.EditorGroup + if err := left.OpenFile(path); err != nil { + t.Fatal(err) + } + h.exec("editor.split") + right := h.app.EditorGroup + if err := right.OpenFile(path); err != nil { + t.Fatal(err) + } + h.pressRune('x') + h.app.Root.SetFocus(left) + if err := left.OpenFile(path); err != nil { + t.Fatal(err) + } + if h.app.EditorGroup != left || left.BufferForPath(path) != right.ActiveBuffer() { + t.Fatal("opening an existing file did not share its buffer in the focused group") + } + h.app.Root.SetFocus(left) + if err := left.OpenFile(filepath.Join(h.dir, "beta.txt")); err != nil { + t.Fatal(err) + } + h.exec("editor.quit") + if !h.app.Root.HasOverlay() || !*h.app.Running { + t.Fatal("quit ignored unsaved edits in the inactive group") + } + h.pressKey(tcell.KeyEscape, 0) + if err := left.OpenFile(filepath.Join(h.dir, "beta.txt")); err != nil { + t.Fatal(err) + } + h.pressRune('y') + h.app.Settings.Editor.SaveAllOnQuit = true + h.app.Settings.Editor.SaveAllOnQuitExceptUnnamed = false + h.exec("editor.quit") + h.pressRune('s') + content, err := os.ReadFile(path) + if err != nil || string(content) != "xa\n" || *h.app.Running { + t.Fatalf("save all and quit = %q, %v, running %v", content, err, *h.app.Running) + } + if content, err := os.ReadFile(filepath.Join(h.dir, "beta.txt")); err != nil || string(content) != "yb\n" { + t.Fatalf("save all missed the other group: %q, %v", content, err) + } + h.app.CloseEditorSplit() +} + +func chooseEditorMore(t *testing.T, h *testHarness, label string) { + t.Helper() + h.redraw() + r := h.app.EditorGroup.TabBar.MoreButton.GetRect() + h.click(r.X+1, r.Y) + menu, ok := h.app.Root.Focused.(*ui.ContextMenuWidget) + if !ok { + t.Fatalf("More menu = %T", h.app.Root.Focused) + } + index := -1 + for i, item := range menu.Items { + if item.Label == label { + index = i + break + } + } + if index < 0 { + t.Fatalf("More menu = %T, want first item %q", h.app.Root.Focused, label) + } + for menu.Selected != index { + h.pressKey(tcell.KeyDown, 0) + } + h.pressKey(tcell.KeyEnter, 0) +} + +func TestEditorSplitFourGroupsAndDirectionalFocus(t *testing.T) { + h := newTestHarness(t, 180, 50) + defer h.stop() + leftTop := h.app.EditorGroup + h.exec("editor.split") + rightTop := h.app.EditorGroup + h.exec("editor.splitDown") + rightBottom := h.app.EditorGroup + h.exec("focus.editorLeft") + if h.app.EditorGroup != leftTop { + t.Fatal("left focus did not reach the adjacent group") + } + h.exec("editor.splitDown") + leftBottom := h.app.EditorGroup + if len(h.app.EditorSplit.Groups()) != 4 { + t.Fatal("split did not create four groups") + } + h.exec("editor.split") + if len(h.app.EditorSplit.Groups()) != 4 { + t.Fatal("group limit was not enforced") + } + h.exec("focus.editorUp") + if h.app.EditorGroup != leftTop { + t.Fatal("up focus did not reach the adjacent group") + } + h.exec("focus.editorRight") + if h.app.EditorGroup != rightTop { + t.Fatal("right focus did not reach the adjacent group") + } + h.exec("focus.editorDown") + if h.app.EditorGroup != rightBottom { + t.Fatal("down focus did not reach the adjacent group") + } + h.pressCtrl(tcell.KeyCtrlK) + h.pressKey(tcell.KeyLeft, 0) + if h.app.EditorGroup != leftBottom { + t.Fatal("directional focus chord did not reach the left group") + } + if !leftBottom.TabBar.GroupActive || rightBottom.TabBar.GroupActive || !rightTop.TabBar.ShowGroupFocus { + t.Fatal("active group indication did not follow focus") + } + h.exec("editor.equalizeGroups") + if delta := leftTop.GetRect().H - leftBottom.GetRect().H; delta < -1 || delta > 1 { + t.Fatal("equalizing did not balance row heights") + } + crossX := leftTop.GetRect().X + leftTop.GetRect().W + assertEditorDividerSkipsTabs(t, h, crossX) + groupCount, activeCount := 0, 0 + var inspect func(*app.DebugWidgetNode) + inspect = func(node *app.DebugWidgetNode) { + if node.Type == "EditorGroup" { + groupCount++ + if node.Props["active_group"] == true { + activeCount++ + } + } + for _, child := range node.Children { + inspect(child) + } + } + inspect(h.app.BuildDebugState().WidgetTree) + if groupCount != 4 || activeCount != 1 { + t.Fatalf("debug reported %d groups, %d active", groupCount, activeCount) + } + + for len(h.app.EditorSplit.Groups()) > 1 { + h.exec("editor.closeSplit") + } +} + +func TestEditorSplitCloseLastSharedView(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + left := h.app.EditorGroup + alpha, beta := filepath.Join(h.dir, "alpha.txt"), filepath.Join(h.dir, "beta.txt") + left.OpenFile(alpha) + h.exec("editor.split") + if err := h.app.EditorGroup.OpenFile(alpha); err != nil { + t.Fatal(err) + } + right := h.app.EditorGroup + if left.ActiveBuffer() != right.ActiveBuffer() || len(h.app.EditorSplit.Groups()) != 2 { + t.Fatal("opening an existing file did not share the document") + } + h.pressRune('x') + h.exec("tab.close") + if len(h.app.EditorSplit.Groups()) != 1 || h.app.Root.HasOverlay() || !left.ActiveBuffer().Dirty { + t.Fatal("closing one shared view lost the document or kept an empty group") + } + h.exec("editor.split") + if err := h.app.EditorGroup.OpenFile(beta); err != nil { + t.Fatal(err) + } + if h.app.EditorGroup.ActiveFilePath() != beta || left.ActiveFilePath() != alpha { + t.Fatal("opening another file in a split replaced the original document") + } + h.exec("editor.closeSplit") + left.SwitchToTabByPath(alpha) + h.app.Root.SetFocus(left) + h.exec("tab.close") + if !h.app.Root.HasOverlay() { + t.Fatal("closing the last dirty view did not ask to save") + } + h.pressKey(tcell.KeyEscape, 0) +} + +func TestEditorSplitReloadSettingsAndSharedUndo(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + path := filepath.Join(h.dir, "alpha.txt") + left := h.app.EditorGroup + left.OpenFile(path) + h.exec("editor.split") + if err := h.app.EditorGroup.OpenFile(path); err != nil { + t.Fatal(err) + } + right := h.app.EditorGroup + if err := os.WriteFile(path, []byte("external\n"), 0644); err != nil { + t.Fatal(err) + } + h.app.HandleFileChanged(path) + h.redraw() + if right.ActiveBuffer().Lines[0] != "external" || left.Editor.Undo != right.Editor.Undo { + t.Fatal("external reload split the shared document or undo history") + } + h.pressRune('x') + h.app.Root.SetFocus(left) + h.exec("editor.undo") + if right.ActiveBuffer().Lines[0] != "external" || left.ActiveBuffer().Dirty { + t.Fatal("undo in the other group did not restore the reloaded document") + } + s := *h.app.Settings + s.Editor.LineNumbers = false + s.Editor.IndentGuides = true + h.app.ApplySettings(s) + if left.Editor.LineNumbers || right.Editor.LineNumbers || !left.Editor.IndentGuides || !right.Editor.IndentGuides { + t.Fatal("live settings were applied to only one group") + } +} + +func TestEditorSplitInheritsKeyboardModifierPreference(t *testing.T) { + h := newTestHarness(t, 140, 32) + defer h.stop() + h.app.Root.SetControlModifiers(tcell.ModMeta) + h.exec("editor.split") + h.typeText("one two") + h.pressKey(tcell.KeyLeft, tcell.ModMeta) + if _, col := h.app.EditorGroup.ActiveCursor(); col != 4 { + t.Fatalf("word movement in the new group ignored Super preference: col %d", col) + } + h.app.Root.SetControlModifiers(tcell.ModCtrl) + h.pressKey(tcell.KeyEnd, 0) + h.pressKey(tcell.KeyLeft, tcell.ModCtrl) + if _, col := h.app.EditorGroup.ActiveCursor(); col != 4 { + t.Fatalf("new group did not follow changed modifier preference: col %d", col) + } +} + +func TestEditorSplitBorderHighlightIsOptionalAndLeavesTabNamesAlone(t *testing.T) { + h := newTestHarness(t, 160, 42) + defer h.stop() + left := h.app.EditorGroup + h.exec("editor.split") + right := h.app.EditorGroup + check := func(group *ui.EditorGroupWidget, border term.Style) { + t.Helper() + cells := h.renderer.NextFrame(h.app.Root.Width, h.app.Root.Height) + h.app.Root.Render(cells) + r := group.TabBar.GetRect() + if cells[r.Y][r.X].Style != border { + t.Fatalf("tab border style = %v, want %v", cells[r.Y][r.X].Style, border) + } + if cells[r.Y+1][r.X+2].Style != term.StyleActiveTab { + t.Fatal("split focus changed the tab name background") + } + } + check(left, term.StyleBorder) + check(right, term.StyleBorderActive) + s := *h.app.Settings + s.Appearance.HighlightActiveGroupBorder = false + h.app.ApplySettings(s) + check(right, term.StyleBorder) + if !left.Editor.Passive || right.Editor.Passive { + t.Fatal("turning border highlighting off changed active-line ownership") + } + s.Appearance.HighlightActiveGroupBorder = true + h.app.ApplySettings(s) + check(left, term.StyleBorder) + check(right, term.StyleBorderActive) + h.app.Root.SetFocus(left) + check(left, term.StyleBorderActive) + check(right, term.StyleBorder) + h.exec("editor.splitDown") + check(h.app.EditorGroup, term.StyleBorderActive) + s.Appearance.HighlightActiveGroupBorder = false + h.app.ApplySettings(s) + for _, group := range h.app.EditorSplit.Groups() { + check(group, term.StyleBorder) + } +} diff --git a/tests/e2e/harness_test.go b/tests/e2e/harness_test.go index 1bb85df23..639af11e6 100644 --- a/tests/e2e/harness_test.go +++ b/tests/e2e/harness_test.go @@ -105,7 +105,10 @@ func newTestHarness(t testing.TB, w, h int) *testHarness { highlight.SetStepScheduler(func() {}) t.Cleanup(func() { editor.CloseTasks() - editor.EditorGroup.CloseAllTabs() + for _, group := range editor.EditorSplit.Groups() { + group.OnEmpty = nil + group.CloseAllTabs() + } highlight.SetStepScheduler(nil) }) h2.redraw() @@ -136,9 +139,7 @@ func (h *testHarness) redraw() { } func (h *testHarness) flushOnChange() { - if h.app.EditorGroup.Editor != nil { - h.app.EditorGroup.Editor.FlushOnChange() - } + h.app.FlushEditorOnChange() } func (h *testHarness) pressKey(key tcell.Key, mod tcell.ModMask) { diff --git a/tests/e2e/plugin_tab_focus_test.go b/tests/e2e/plugin_tab_focus_test.go index a1ad32c31..3a2fe12f1 100644 --- a/tests/e2e/plugin_tab_focus_test.go +++ b/tests/e2e/plugin_tab_focus_test.go @@ -131,3 +131,32 @@ func TestPluginOpenTabFocusFalseAndFocusTab(t *testing.T) { t.Fatalf("typed %q after focus_tab, want ok (focus %T)", got, h.app.Root.Focused) } } + +func TestPluginTabOperationsFindInactiveSplit(t *testing.T) { + h := newTestHarness(t, 140, 36) + defer h.stop() + p := loadFormsPlugin(t, h) + h.exec("forms.open") + owner := h.app.EditorGroup + h.exec("editor.split") + if err := p.State.DoString(`found = require("ttt").focus_tab("form")`); err != nil { + t.Fatal(err) + } + h.redraw() + if p.State.GetGlobal("found") != lua.LTrue || h.app.EditorGroup != owner { + t.Fatal("focus_tab did not reach the owning split") + } + typeInto(h, "ok") + if got := typedText(p); got != "ok" { + t.Fatalf("typed %q, want ok", got) + } + h.exec("editor.splitDown") + h.app.RemovePluginPanels("forms") + for _, group := range h.app.EditorSplit.Groups() { + for i := range group.TabCount() { + if group.PluginTabKey(i) == "plugin:forms:form" { + t.Fatal("plugin cleanup left an inactive tab") + } + } + } +} From e7249f47a4bf20e8c880cf64ab984a1f07c472cd Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 14 Nov 2026 15:52:42 -0800 Subject: [PATCH 2/4] fix(editor): preserve split ownership across lifecycle updates --- README.md | 2 +- internal/app/app_lsp.go | 2 +- internal/app/callbacks.go | 26 ++++- internal/app/editor_split.go | 18 ++- internal/app/editor_split_review_test.go | 137 +++++++++++++++++++++++ internal/app/gitgutter.go | 4 +- internal/app/gitgutter_test.go | 28 +++++ internal/app/pr.go | 26 +++-- internal/ui/editor_group.go | 2 +- internal/ui/editor_group_shared.go | 13 ++- internal/ui/editor_group_split.go | 2 +- internal/ui/editor_group_split_test.go | 41 +++++++ 12 files changed, 276 insertions(+), 25 deletions(-) create mode 100644 internal/app/editor_split_review_test.go diff --git a/README.md b/README.md index ef9d63fdd..5fa4744bb 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,7 @@ All keybindings are customizable via [`keybindings.json`](config/keybindings.jso Set [`keyboard.modifierKey`](docs-web/src/content/docs/reference/settings.md#keyboard) to `"control+super"` to also accept Super/Cmd for Ctrl shortcuts, or `"super"` to use Super/Cmd instead. The default is `"control"`. Binding definitions stay unchanged, and the terminal must forward the shortcuts. -Use the editor tab bar’s **More** menu to split right or down (up to four groups), or close a split. Each new group starts with an independent, numbered untitled document; opening a file replaces the untouched placeholder. The command palette groups split actions under **Split:**, including **Split: Equalize Sizes** and **Split: Move to Other Group**. Views of the same document share text and undo history, with independent cursors and scrolling. **Ctrl+K Left/Right/Up/Down** focuses the group in that direction; **F6** and **Shift+F6** cycle groups, the sidebar, and the panel. Drag tabs between groups, or use **Open to the Side** in the Explorer or tab menu. Closing the last tab removes its group; **Close Split** merges its remaining tabs into a neighbor. +Use the editor tab bar’s **More** menu to split right or down (up to four groups), or close a split. Each new group starts with an independent, numbered untitled document; opening a file replaces the untouched placeholder. The command palette groups split actions under **Split:**, including **Split: Equalize Sizes**. Views of the same document share text and undo history, with independent cursors and scrolling. **Ctrl+K Left/Right/Up/Down** focuses the group in that direction; **F6** and **Shift+F6** cycle groups, the sidebar, and the panel. Closing the last tab removes its group; **Close Split** merges its remaining tabs into a neighbor. The full default keybinding list is in [`docs-web/src/content/docs/reference/keybindings.md`](docs-web/src/content/docs/reference/keybindings.md). diff --git a/internal/app/app_lsp.go b/internal/app/app_lsp.go index 4d1a6a899..01fcf8966 100644 --- a/internal/app/app_lsp.go +++ b/internal/app/app_lsp.go @@ -654,7 +654,7 @@ func (a *App) snapshotCodeActionBuffers() map[string]codeActionBufferSnapshot { snapshots := make(map[string]codeActionBufferSnapshot) for _, group := range a.editorGroups() { for _, path := range group.OpenFilePaths() { - if buf := group.BufferForPath(path); buf != nil { + if buf := a.editorBufferForPath(path); buf != nil { snapshots[path] = codeActionBufferSnapshot{Buffer: buf, Version: buf.Version()} } } diff --git a/internal/app/callbacks.go b/internal/app/callbacks.go index f18b855c4..cc9782c94 100644 --- a/internal/app/callbacks.go +++ b/internal/app/callbacks.go @@ -181,9 +181,14 @@ func (a *App) ShowChangesFolderContextMenu(dir, _ string, staged bool, sx, sy in func (a *App) DiffSearchSources() []ui.DiffSearchSource { seen := map[string]bool{} - sources := a.EditorGroup.DiffTabSources() - for _, s := range sources { - seen[s.TabName] = true + var sources []ui.DiffSearchSource + for _, group := range append([]*ui.EditorGroupWidget{a.EditorGroup}, a.editorGroups()...) { + for _, source := range group.DiffTabSources() { + if !seen[source.TabName] { + seen[source.TabName] = true + sources = append(sources, source) + } + } } for _, g := range a.Changes.Groups() { if !g.IsPR { @@ -202,7 +207,7 @@ func (a *App) DiffSearchSources() []ui.DiffSearchSource { func (a *App) NavigateToSearchMatch(path string, line, col int) { if strings.HasSuffix(path, " (diff)") { - if !a.EditorGroup.SwitchToTabByPath(path) { + if !a.switchToDiffTab(path) { filePath := strings.TrimSuffix(path, " (diff)") for _, g := range a.Changes.Groups() { if !g.IsPR { @@ -974,3 +979,16 @@ func (app *App) editorTabChanged() { } app.applySearchHighlights() } + +func (a *App) switchToDiffTab(path string) bool { + if a.EditorGroup.SwitchToTabByPath(path) { + return true + } + for _, group := range a.editorGroups() { + if group.DiffWidgetByTab(path) != nil { + a.activateEditorGroup(group) + return group.SwitchToTabByPath(path) + } + } + return false +} diff --git a/internal/app/editor_split.go b/internal/app/editor_split.go index 82b576ff4..645672dd0 100644 --- a/internal/app/editor_split.go +++ b/internal/app/editor_split.go @@ -98,7 +98,10 @@ func (a *App) removeEditorGroup(closing *ui.EditorGroupWidget, merge bool) { break } } - a.activateEditorGroup(keep) + wasActive := closing == a.EditorGroup + if wasActive { + a.activateEditorGroup(keep) + } closing.OnEmpty = nil if merge { keep.DiscardDuplicateViews(closing) @@ -113,7 +116,9 @@ func (a *App) removeEditorGroup(closing *ui.EditorGroupWidget, merge bool) { if closing.FoldDelay != nil { a.releaseTask(closing.FoldDelay) } - a.FocusEditor() + if wasActive { + a.FocusEditor() + } } func (a *App) focusEditorAfterClick() { @@ -225,3 +230,12 @@ func (a *App) editorBufferForPath(path string) *buffer.Buffer { } return nil } + +func (a *App) isInlineDiffPath(path string) bool { + for _, group := range a.editorGroups() { + if group.IsInlineDiffPath(path) { + return true + } + } + return false +} diff --git a/internal/app/editor_split_review_test.go b/internal/app/editor_split_review_test.go new file mode 100644 index 000000000..3e981f7ae --- /dev/null +++ b/internal/app/editor_split_review_test.go @@ -0,0 +1,137 @@ +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/eugenioenko/ttt/internal/config" + "github.com/eugenioenko/ttt/internal/core/diff" + "github.com/eugenioenko/ttt/internal/plugin" + "github.com/eugenioenko/ttt/internal/ui" + "github.com/eugenioenko/ttt/internal/widgets" +) + +func splitReviewApp(t *testing.T) (*App, string) { + t.Helper() + config.OverrideConfigDir = t.TempDir() + t.Cleanup(func() { config.OverrideConfigDir = "" }) + a := buildTestApp(t, config.DefaultSettings()) + a.wireEditorGroupActivation(a.EditorGroup) + a.Root.SetFocus(a.EditorGroup) + t.Cleanup(a.CloseTasks) + path := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(path, []byte("working document\n"), 0600); err != nil { + t.Fatal(err) + } + return a, path +} + +func TestCodeActionSnapshotUsesSelectedBufferAcrossGroups(t *testing.T) { + a, path := splitReviewApp(t) + if err := a.EditorGroup.OpenFile(path); err != nil { + t.Fatal(err) + } + working := a.EditorGroup + a.SplitEditor() + a.EditorGroup.OpenFileReadOnly(path, "Snapshot") + a.Root.SetFocus(working) + if got := a.snapshotCodeActionBuffers()[path].Buffer; got != a.editorBufferForPath(path) { + t.Fatal("snapshot selected a different buffer than code-action validation") + } +} + +func TestOpenWorkingFileDoesNotShareReadOnlyPeer(t *testing.T) { + for _, memory := range []bool{false, true} { + t.Run(map[bool]string{false: "file", true: "memory"}[memory], func(t *testing.T) { + a, path := splitReviewApp(t) + if memory { + a.EditorGroup.OpenBufferReadOnly("Snapshot", path, []string{"historical"}) + } else { + a.EditorGroup.OpenFileReadOnly(path, "Snapshot") + } + readOnly := a.EditorGroup.ActiveBuffer() + a.SplitEditor() + if err := a.EditorGroup.OpenFile(path); err != nil { + t.Fatal(err) + } + if a.EditorGroup.ActiveBuffer() == readOnly || a.EditorGroup.Editor.ReadOnly || a.EditorGroup.ActiveBuffer().Lines[0] != "working document" { + t.Fatal("working-file open reused the read-only snapshot") + } + }) + } +} + +func TestInactiveGroupRemovalPreservesActiveGroupAndFocus(t *testing.T) { + for _, sidebar := range []bool{false, true} { + t.Run(map[bool]string{false: "editor", true: "sidebar"}[sidebar], func(t *testing.T) { + a, _ := splitReviewApp(t) + a.SplitEditor() + closing := a.EditorGroup + closing.OpenPluginTab("temporary", "Temporary", widgets.NewInputWidget(widgets.InputConfig{})) + closing.CloseOtherTabs() + a.SplitEditorDown() + active := a.EditorGroup + if sidebar { + a.FocusSidebar() + } + focused := a.Root.Focused + closing.ClosePluginTab("temporary") + if len(a.editorGroups()) != 2 || a.EditorGroup != active || a.Root.Focused != focused { + t.Fatal("removing an inactive group moved the active group or keyboard focus") + } + }) + } +} + +func TestMergedSameNameDiffsReceiveTheirOwnResults(t *testing.T) { + a, owner, other := splitResultApp() + owner.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) + other.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) + target := other.ActiveDiffWidget() + target.Loading = true + owner.MergeTabsFrom(other) + a.applyDiffContent(&DiffContentResult{Widget: target, TabName: "file.txt (diff)", OldLines: []string{"old"}, NewLines: []string{"new"}}) + if target.Loading { + t.Fatal("same-name tab shadowed the retained widget's result") + } +} + +func TestDiffSearchIncludesInactiveGroupsAndDeduplicatesTabs(t *testing.T) { + a, _ := splitReviewApp(t) + a.EditorGroup.OpenDiff("first.txt", diff.FileDiff{}, []string{"old"}, []string{"new"}, false) + owner := a.EditorGroup + a.SplitEditor() + a.EditorGroup.OpenDiff("first.txt", diff.FileDiff{}, []string{"old"}, []string{"new"}, false) + a.EditorGroup.OpenDiff("second.txt", diff.FileDiff{}, []string{"old"}, []string{"new"}, false) + a.SplitEditorDown() + if got := a.DiffSearchSources(); len(got) != 2 { + t.Fatalf("diff search sources = %d, want 2", len(got)) + } + a.NavigateToSearchMatch("first.txt (diff)", 1, 1) + if a.EditorGroup != owner || a.EditorGroup.ActiveDiffWidget() == nil { + t.Fatal("search navigation did not focus the owning diff group") + } +} + +func TestHandledFileOwnerSurvivesClosingOneView(t *testing.T) { + a, path := splitReviewApp(t) + left := a.EditorGroup + left.OnContentTabClose = a.closeFileHandlerTab + left.OpenPluginTab(path, "Handled", widgets.NewInputWidget(widgets.InputConfig{})) + a.SplitEditor() + right := a.EditorGroup + right.OpenPluginTab(path, "Handled", widgets.NewInputWidget(widgets.InputConfig{})) + a.fileHandlerTabs = map[string]*plugin.Plugin{path: {Name: "handler"}} + left.ClosePluginTab(path) + if a.fileHandlerTabs[path] == nil { + t.Fatal("closing one view forgot the surviving handler owner") + } + a.SyncFileHandlerTabs() + if _, ok := right.TabContent(right.ActiveTabIndex()).(*ui.BinaryFileWidget); !ok { + t.Fatal("unloaded handler left its surviving view mounted") + } + if a.fileHandlerTabs[path] != nil { + t.Fatal("unloaded handler ownership remained") + } +} diff --git a/internal/app/gitgutter.go b/internal/app/gitgutter.go index 12bbe9b5e..affe66b69 100644 --- a/internal/app/gitgutter.go +++ b/internal/app/gitgutter.go @@ -35,7 +35,7 @@ func (a *App) RequestGitGutter(filePath string, bufferLines []string) { a.gitGutterTask.Stop() } gutterOn := a.Settings.Editor.IsGitGutterEnabled() - diffOn := a.EditorGroup.IsInlineDiffPath(filePath) + diffOn := a.isInlineDiffPath(filePath) if !gutterOn && !diffOn { return } @@ -147,7 +147,7 @@ func (a *App) ScheduleGitGutter() { a.gitGutterTask.Stop() } path := a.EditorGroup.ActiveFilePath() - diffOn := a.EditorGroup.IsInlineDiffPath(path) + diffOn := a.isInlineDiffPath(path) if a.EditorGroup.ActiveBuffer() == nil || path == "" || a.EditorGroup.IsActiveVirtual() || (!a.Settings.Editor.IsGitGutterEnabled() && !diffOn) { return } diff --git a/internal/app/gitgutter_test.go b/internal/app/gitgutter_test.go index 0b5823494..3eccd45b6 100644 --- a/internal/app/gitgutter_test.go +++ b/internal/app/gitgutter_test.go @@ -274,3 +274,31 @@ func TestGitGutterDropsQueuedDebounceOnEdit(t *testing.T) { t.Fatalf("gutter changes = %v, want added line 1", changes) } } + +func TestInactiveInlineDiffRequestsRefreshWithGutterDisabled(t *testing.T) { + for _, delayed := range []bool{false, true} { + t.Run(map[bool]string{false: "request", true: "schedule"}[delayed], func(t *testing.T) { + h := newGitGutterTestHarness(t) + a := h.a + owner, path := a.EditorGroup, a.EditorGroup.ActiveFilePath() + if !owner.EnableInlineDiff(path, []string{"one", "two", ""}) { + t.Fatal("inline diff did not open") + } + a.SplitEditor() + if err := a.EditorGroup.OpenFile(path); err != nil { + t.Fatal(err) + } + enabled := false + a.Settings.Editor.GitGutter = &enabled + if delayed { + a.ScheduleGitGutter() + h.clock.advance(gitGutterDelay) + } else { + a.RequestGitGutter(path, a.EditorGroup.ActiveBuffer().Lines) + } + if len(h.work) != 1 { + t.Fatalf("queued refresh work = %d, want 1 for the inactive inline diff", len(h.work)) + } + }) + } +} diff --git a/internal/app/pr.go b/internal/app/pr.go index 60dc3a2d2..497112663 100644 --- a/internal/app/pr.go +++ b/internal/app/pr.go @@ -26,19 +26,21 @@ type DiffContentResult struct { func (a *App) applyDiffContent(result *DiffContentResult) { for _, group := range a.editorGroups() { - dv := group.DiffWidgetByTab(result.TabName) - if dv == nil || dv != result.Widget { - continue - } - if result.Err != nil { - dv.FailLoading() - a.StatusError(i18n.Format("status.failedToFetchFileContent", i18n.Args{"error": result.Err.Error()})) - } else { - dv.SetOldLines(result.OldLines) - dv.SetNewLines(result.NewLines) - dv.FinishLoading() + for i := range group.TabCount() { + dv, ok := group.TabContent(i).(*ui.DiffEditorWidget) + if !ok || dv != result.Widget { + continue + } + if result.Err != nil { + dv.FailLoading() + a.StatusError(i18n.Format("status.failedToFetchFileContent", i18n.Args{"error": result.Err.Error()})) + } else { + dv.SetOldLines(result.OldLines) + dv.SetNewLines(result.NewLines) + dv.FinishLoading() + } + return } - return } } diff --git a/internal/ui/editor_group.go b/internal/ui/editor_group.go index aff46cf8c..952b61a34 100644 --- a/internal/ui/editor_group.go +++ b/internal/ui/editor_group.go @@ -1179,7 +1179,7 @@ func (g *EditorGroupWidget) releaseTabResources(tab editorTab) { if closer, ok := tab.Content.(interface{ Close() }); ok { closer.Close() } - if g.OnContentTabClose != nil { + if g.OnContentTabClose != nil && !g.otherContentView(tab) { g.OnContentTabClose(tab.FilePath) } } diff --git a/internal/ui/editor_group_shared.go b/internal/ui/editor_group_shared.go index dda6bda35..02df9c776 100644 --- a/internal/ui/editor_group_shared.go +++ b/internal/ui/editor_group_shared.go @@ -40,7 +40,7 @@ func (g *EditorGroupWidget) CloneActiveTabTo(target *EditorGroupWidget) bool { func (g *EditorGroupWidget) OpenSharedFileFrom(source *EditorGroupWidget, path string) bool { for i := range source.tabs { - if source.tabs[i].FilePath == path && source.tabs[i].Content == nil { + if source.tabs[i].FilePath == path && source.tabs[i].Content == nil && !source.tabs[i].ReadOnly { return source.cloneTabTo(i, g) } } @@ -285,3 +285,14 @@ func (g *EditorGroupWidget) DiscardDuplicateViews(source *EditorGroupWidget) { source.active = min(source.active, len(source.tabs)-1) source.syncTabs() } + +func (g *EditorGroupWidget) otherContentView(tab editorTab) bool { + for _, group := range g.relatedGroups() { + for _, other := range group.tabs { + if other.Content != nil && other.FilePath == tab.FilePath && (group != g || other.ID != tab.ID) { + return true + } + } + } + return false +} diff --git a/internal/ui/editor_group_split.go b/internal/ui/editor_group_split.go index 85a9a47c3..6ad69aed7 100644 --- a/internal/ui/editor_group_split.go +++ b/internal/ui/editor_group_split.go @@ -50,7 +50,7 @@ func (g *EditorGroupWidget) MoveActiveTabTo(target *EditorGroupWidget) { } return } - if len(target.tabs) == 1 && target.isUntouchedPlaceholder(target.activeTab()) { + if len(target.tabs) == 1 && !target.IsActiveTabPinned() && target.isUntouchedPlaceholder(target.activeTab()) { target.tabs = nil target.pinnedCount = 0 } diff --git a/internal/ui/editor_group_split_test.go b/internal/ui/editor_group_split_test.go index 3db4b62f1..a563c2fef 100644 --- a/internal/ui/editor_group_split_test.go +++ b/internal/ui/editor_group_split_test.go @@ -17,3 +17,44 @@ func TestMergeGroupsKeepsEditedTabsAfterAnUntouchedPlaceholder(t *testing.T) { t.Fatal("merging a group skipped the edited document behind its empty placeholder") } } + +func TestMergeGroupsPreservesPinnedTargetPlaceholder(t *testing.T) { + source := NewEditorGroupWidget(nil, 4, true, "relative") + target := NewEditorGroupWidget(nil, 4, true, "relative") + target.TogglePinTab() + pinned := target.ActiveBuffer() + source.Editor.HandleEvent(tcell.NewEventKey(tcell.KeyRune, "x", 0)) + target.MergeTabsFrom(source) + if target.TabCount() != 2 || target.tabs[0].Buf != pinned || target.pinnedCount != 1 { + t.Fatal("merging discarded the pinned target placeholder") + } +} + +type closeCountContent struct { + BinaryFileWidget + closed int +} + +func (w *closeCountContent) Close() { w.closed++ } + +func TestContentViewsReleaseEachWidgetAndNotifyLastClose(t *testing.T) { + left := NewEditorGroupWidget(nil, 4, true, "relative") + right := NewEditorGroupWidget(nil, 4, true, "relative") + groups := func() []*EditorGroupWidget { return []*EditorGroupWidget{left, right} } + first, second := &closeCountContent{}, &closeCountContent{} + closed := 0 + for _, group := range groups() { + group.RelatedGroups = groups + group.OnContentTabClose = func(string) { closed++ } + } + left.OpenPluginTab("handled", "Handled", first) + right.OpenPluginTab("handled", "Handled", second) + left.CloseTab() + if first.closed != 1 || closed != 0 { + t.Fatal("closing one view released the document or retained its widget") + } + right.CloseTab() + if second.closed != 1 || closed != 1 { + t.Fatal("last view did not release its widget and document exactly once") + } +} From 7a77f6ece57c5ec375471271fdeb519e306ace43 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 14 Nov 2026 16:08:25 -0800 Subject: [PATCH 3/4] fix(editor): preserve pinned placeholders on shared opens --- internal/app/editor_split_review_test.go | 8 ++++++-- internal/ui/editor_group_shared.go | 2 +- internal/ui/editor_group_shared_test.go | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/app/editor_split_review_test.go b/internal/app/editor_split_review_test.go index 3e981f7ae..4cefe300d 100644 --- a/internal/app/editor_split_review_test.go +++ b/internal/app/editor_split_review_test.go @@ -33,11 +33,15 @@ func TestCodeActionSnapshotUsesSelectedBufferAcrossGroups(t *testing.T) { t.Fatal(err) } working := a.EditorGroup + workingBuffer := working.ActiveBuffer() a.SplitEditor() a.EditorGroup.OpenFileReadOnly(path, "Snapshot") + if a.EditorGroup.ActiveBuffer() == workingBuffer { + t.Fatal("read-only peer must have a separate snapshot buffer") + } a.Root.SetFocus(working) - if got := a.snapshotCodeActionBuffers()[path].Buffer; got != a.editorBufferForPath(path) { - t.Fatal("snapshot selected a different buffer than code-action validation") + if got := a.snapshotCodeActionBuffers()[path].Buffer; got != workingBuffer { + t.Fatal("snapshot did not retain the independently captured working buffer") } } diff --git a/internal/ui/editor_group_shared.go b/internal/ui/editor_group_shared.go index 02df9c776..c70858e2a 100644 --- a/internal/ui/editor_group_shared.go +++ b/internal/ui/editor_group_shared.go @@ -75,7 +75,7 @@ func (g *EditorGroupWidget) cloneTabTo(index int, target *EditorGroupWidget) boo target.rebuildHighlighter(&clone) clone.rememberView() placeholder := target.activeTab() - if len(target.tabs) == 1 && target.isUntouchedPlaceholder(placeholder) && placeholder.Buf.Version() == 0 { + if len(target.tabs) == 1 && !target.IsActiveTabPinned() && target.isUntouchedPlaceholder(placeholder) && placeholder.Buf.Version() == 0 { target.notifyContentTabClose(*target.activeTab()) target.tabs = nil target.pinnedCount = 0 diff --git a/internal/ui/editor_group_shared_test.go b/internal/ui/editor_group_shared_test.go index 36849c6de..c5c3d28fa 100644 --- a/internal/ui/editor_group_shared_test.go +++ b/internal/ui/editor_group_shared_test.go @@ -109,8 +109,9 @@ func TestClonedDocumentDoesNotInheritPlaceholderPin(t *testing.T) { source := newRenameGroup(t, filepath.Join(t.TempDir(), "file.txt"), "text") target := NewEditorGroupWidget(nil, 4, true, "relative") target.TogglePinTab() - if !source.CloneActiveTabTo(target) || target.TabCount() != 1 || target.IsActiveTabPinned() { - t.Fatal("cloned document inherited the replaced placeholder's pin") + placeholder := target.ActiveBuffer() + if !source.CloneActiveTabTo(target) || target.TabCount() != 2 || target.IsActiveTabPinned() || target.pinnedCount != 1 || target.tabs[0].Buf != placeholder { + t.Fatal("shared open discarded the pinned placeholder or inherited its pin") } } From 4ff34c0ffbc0d9dd00b248573e3e256f8ac6da09 Mon Sep 17 00:00:00 2001 From: eugenioenko Date: Sat, 14 Nov 2026 16:15:51 -0800 Subject: [PATCH 4/4] test(editor): verify merged diff content delivery --- internal/app/editor_split_review_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/app/editor_split_review_test.go b/internal/app/editor_split_review_test.go index 4cefe300d..6dd8fe43c 100644 --- a/internal/app/editor_split_review_test.go +++ b/internal/app/editor_split_review_test.go @@ -3,6 +3,7 @@ package app import ( "os" "path/filepath" + "slices" "testing" "github.com/eugenioenko/ttt/internal/config" @@ -91,11 +92,17 @@ func TestInactiveGroupRemovalPreservesActiveGroupAndFocus(t *testing.T) { func TestMergedSameNameDiffsReceiveTheirOwnResults(t *testing.T) { a, owner, other := splitResultApp() owner.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) - other.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, false) + other.OpenDiff("file.txt", diff.FileDiff{}, nil, nil, true) target := other.ActiveDiffWidget() target.Loading = true owner.MergeTabsFrom(other) a.applyDiffContent(&DiffContentResult{Widget: target, TabName: "file.txt (diff)", OldLines: []string{"old"}, NewLines: []string{"new"}}) + if !slices.Contains(target.LeftLines(), "old") || !slices.Contains(target.RightLines(), "new") { + t.Fatal("retained diff did not receive both content snapshots") + } + if slices.Contains(owner.ActiveDiffWidget().LeftLines(), "old") || slices.Contains(owner.ActiveDiffWidget().RightLines(), "new") { + t.Fatal("content was delivered to the same-named sibling") + } if target.Loading { t.Fatal("same-name tab shadowed the retained widget's result") }