Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**. 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).

## Testing
Expand Down
4 changes: 4 additions & 0 deletions config/keybindings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 2 additions & 1 deletion config/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"appearance": {
"icons": "none",
"scrollbarGlyphs": "blocks",
"pointerShapes": true
"pointerShapes": true,
"highlightActiveGroupBorder": true
},
"editor": {
"autoClosing": {
Expand Down
14 changes: 12 additions & 2 deletions docs-web/src/content/docs/guides/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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" }
]
```

Expand Down
8 changes: 6 additions & 2 deletions docs-web/src/content/docs/reference/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs-web/src/content/docs/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
11 changes: 4 additions & 7 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}))
})
Expand Down Expand Up @@ -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()
}
Expand Down
10 changes: 6 additions & 4 deletions internal/app/app_lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 := a.editorBufferForPath(path); buf != nil {
snapshots[path] = codeActionBufferSnapshot{Buffer: buf, Version: buf.Version()}
Comment thread
eugenioenko marked this conversation as resolved.
Resolved
}
}
}
return snapshots
Expand Down
75 changes: 45 additions & 30 deletions internal/app/callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -276,7 +281,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
}
Expand All @@ -300,7 +305,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)}))
}
Expand Down Expand Up @@ -332,7 +337,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
}
Expand All @@ -357,7 +362,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()
Expand Down Expand Up @@ -561,6 +566,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,
Expand Down Expand Up @@ -730,16 +736,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)
Expand Down Expand Up @@ -786,11 +785,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()
Expand Down Expand Up @@ -888,12 +882,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() {
Expand Down Expand Up @@ -970,10 +959,36 @@ 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()
}

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
}
25 changes: 19 additions & 6 deletions internal/app/changes_async.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading