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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ What follows is how TuiCode implements it today.
- Visibility is `EditorGroup.GutterVisible` (on by default, applied to open and future tabs), toggled by `tg`. It isn't persisted: that waits for an editor section in Settings.
- Gutter colours come from the token theme's VS Code `colors` (`editorLineNumber.foreground` / `activeForeground`, `editorGutter.addedBackground` / `modifiedBackground` / `deletedBackground`); TG schemes have no semantic roles for them. Without them (no highlighter, e.g. in tests) markers fall back to fixed green/blue/red and line numbers to the Editable attribute, faint except on the cursor row.

## Editor groups (#460)

- `EditorPart` holds two `EditorGroup`s, each in its own bordered `FrameView`, behind `EditorGroups` (TG-free bookkeeping in `TuiCode.Editor`). The second shows only while it holds a tab; whenever either group empties, everything ends up in the first (`EditorGroups.Settle`). A file is open in at most one group: `EditorGroups.Open` focuses it where it is, so open files through it (or `Workbench.OpenFile`), never `EditorGroup.OpenOrFocus`.
- `EditorPart.Group` is the **focused** group, so every "active tab" command acts there. Anything that means *every* open tab — save all, quit, rename/delete, disk watching, baselines, language servers, global find — goes through `Editor.Groups`, which also re-raises both groups' events. Its `ActiveTabChanged` carries the focused group's tab, and fires when the focus moves between groups too.
- Focus follows the keyboard: `WorkbenchHost.Reconcile` sets `Groups.Focused` to the group holding the newly focused view (a click, `F6`), except on the first iteration, where a restored focus on the second group moves the keys there instead. The focused group's frame gets the focus border.
- Moving a tab (`EditorGroup.MoveTo`) unwires its events from the old group and wires them to the new, so nothing is raised twice. Closing a file closes its diffs in either group (`EditorGroup.EditorClosed`).
- The second group persists in the workspace state as `Second` (`Files`, `Active`, `Focused`); an entry without it opens as one group.

## Diff tab (#61)

- **Compare to saved** (`cts`, no default key) opens a read-only `DiffTab` in the editor group: the file on disk on the left, the live buffer on the right, laid out by `AlignedDiff` with `DiffTab.MaxEdits` (5,000) as the give-up limit. `DiffTab.ReadLines` splits the file with TG's own `Cell.StringToLinesOfCells`, so an unedited buffer compares equal.
Expand Down Expand Up @@ -439,6 +447,7 @@ What follows is how TuiCode implements it today.
| Anywhere | `fs` / `ts` / a sidebar tab's shortcut | the sidebar's active tab: `Explorer`, `Find`, `Usages` or `Review` |
| `Editor` | `Ctrl+G U` (`gu`) | `Usages` |
| Anywhere | `fr` (`FocusReview`) | `Review` |
| Anywhere | `F6` / `fo` (`FocusOtherGroup`) | the other editor group's active tab |
| Anywhere | `Ctrl+F` / `Ctrl+H` (`ff` / `rf`) | `FindBar`, or `Find` with no tab open; unchanged on a document, and for `Ctrl+H` on a diff |
| `Review` | `Enter` on a file | `Diff` |
| Anywhere | opening a file, a diff, a document or a history jump | `Editor` or `Diff` |
Expand Down
3 changes: 3 additions & 0 deletions src/TuiCode.Abstractions/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public static class CommandIds
public const string CloseActiveEditor = "workbench.action.closeActiveEditor";
public const string NextEditor = "workbench.action.nextEditor";
public const string PreviousEditor = "workbench.action.previousEditor";
public const string MoveToOtherGroup = "workbench.action.moveEditorToOtherGroup";
public const string FocusOtherGroup = "workbench.action.focusOtherGroup";
public const string JoinGroups = "workbench.action.joinAllGroups";

public const string ToggleSidebar = "workbench.action.toggleSidebar";
public const string WidenSidebar = "workbench.action.widenSidebar";
Expand Down
3 changes: 3 additions & 0 deletions src/TuiCode.Abstractions/CommandMnemonics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ private static IReadOnlyDictionary<string, string> Build()
[CommandIds.CutFile] = "xf",
[CommandIds.PasteFile] = "pf",
[CommandIds.NextEditor] = "nt",
[CommandIds.MoveToOtherGroup] = "mg",
[CommandIds.FocusOtherGroup] = "fo",
[CommandIds.JoinGroups] = "jg",
[CommandIds.PreviousEditor] = "pt",
[CommandIds.Open] = "of",
[CommandIds.OpenSettings] = "os",
Expand Down
98 changes: 83 additions & 15 deletions src/TuiCode.Editor/EditorGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public sealed class EditorGroup : PaneTabs
private readonly Dictionary<string, EditorTab> _byPath = new(StringComparer.Ordinal);
private readonly List<DiffTab> _diffs = [];
private readonly Dictionary<string, DocumentTab> _documents = new(StringComparer.Ordinal);
private readonly Dictionary<EditorTab, Action> _unwire = [];
private readonly SyntaxHighlighter? _syntax;

public event EventHandler<IFileInfo>? FileSaved;
Expand All @@ -19,6 +20,9 @@ public sealed class EditorGroup : PaneTabs

public event EventHandler<EditorTab>? BaselineReset;

/// <summary>Raised as a file's tab closes, so a diff of it in another group can close too.</summary>
public event EventHandler<EditorTab>? EditorClosed;

/// <summary>Raised when any tab's grammar changes, e.g. from the grammar picker or new associations.</summary>
public event EventHandler<EditorTab>? GrammarChanged;

Expand Down Expand Up @@ -135,12 +139,7 @@ private EditorTab Track(EditorTab tab)
tab.IconStyle = IconStyle;
tab.Settings = Settings;
tab.WordWrap = Settings.WrapsLanguage(tab.HasSyntax ? tab.Grammar?.Id ?? SyntaxHighlighter.PlainText : null);
tab.Saved += (_, _) => FileSaved?.Invoke(this, tab.File);
tab.BaselineReset += (_, _) => BaselineReset?.Invoke(this, tab);
tab.Copied += (_, outcome) => Copied?.Invoke(this, outcome);
tab.CursorMoved += (_, p) => CursorMoved?.Invoke(this, (tab.File, p.Row, p.Column));
tab.GrammarChanged += (_, _) => GrammarChanged?.Invoke(this, tab);
tab.PinnedLineClicked += (_, line) => PinnedLineClicked?.Invoke(this, (tab, line));
Wire(tab);
// Tabs selects the first tab it's given during Add, so register it first for ActiveTabChanged listeners to
// see — and announce it before that, so the file is being watched by the time anything reacts to it (#269).
_byPath[tab.File.FullName] = tab;
Expand All @@ -150,6 +149,75 @@ private EditorTab Track(EditorTab tab)
return tab;
}

private void Wire(EditorTab tab)
{
EventHandler saved = (_, _) => FileSaved?.Invoke(this, tab.File);
EventHandler baseline = (_, _) => BaselineReset?.Invoke(this, tab);
EventHandler<CopyOutcome> copied = (_, outcome) => Copied?.Invoke(this, outcome);
EventHandler<(int Row, int Column)> moved = (_, p) => CursorMoved?.Invoke(this, (tab.File, p.Row, p.Column));
EventHandler grammar = (_, _) => GrammarChanged?.Invoke(this, tab);
EventHandler<int> pinned = (_, line) => PinnedLineClicked?.Invoke(this, (tab, line));
tab.Saved += saved;
tab.BaselineReset += baseline;
tab.Copied += copied;
tab.CursorMoved += moved;
tab.GrammarChanged += grammar;
tab.PinnedLineClicked += pinned;
_unwire[tab] = () =>
{
tab.Saved -= saved;
tab.BaselineReset -= baseline;
tab.Copied -= copied;
tab.CursorMoved -= moved;
tab.GrammarChanged -= grammar;
tab.PinnedLineClicked -= pinned;
};
}

/// <summary>Moves <paramref name="tab"/> into <paramref name="other"/> and makes it active there.</summary>
public void MoveTo(EditorGroup other, View tab)
{
var strip = TabCollection.ToList();
if (!strip.Contains(tab)) return;
var before = strip.IndexOf(tab);

if (tab is EditorTab editor)
{
_byPath.Remove(editor.File.FullName);
if (_unwire.Remove(editor, out var unwire)) unwire();
}
if (tab is DiffTab diff) _diffs.Remove(diff);
if (tab is DocumentTab document) _documents.Remove(document.File.FullName);
Remove(tab);

var remaining = TabCollection.ToList();
if (remaining.Count == 0) ClearValue();
else if (Value is null || ReferenceEquals(Value, tab)) Value = remaining[Math.Min(before, remaining.Count - 1)];

other.Adopt(tab);
TabsChanged?.Invoke(this, EventArgs.Empty);
}

private void Adopt(View tab)
{
switch (tab)
{
case EditorTab editor:
Wire(editor);
_byPath[editor.File.FullName] = editor;
break;
case DiffTab diff:
_diffs.Add(diff);
break;
case DocumentTab document:
_documents[document.File.FullName] = document;
break;
}
TabsChanged?.Invoke(this, EventArgs.Empty);
Add(tab);
Value = tab;
}

/// <summary>Returns null, opening nothing, when the buffer matches the file on disk.</summary>
public DiffTab? CompareToSaved(EditorTab source) =>
Compare(source, "saved", () => DiffTab.ReadLines(source.File));
Expand Down Expand Up @@ -274,23 +342,22 @@ private void Close(View tab)

foreach (var closed in closing)
{
if (closed is EditorTab e) _byPath.Remove(e.File.FullName);
if (closed is EditorTab e)
{
_byPath.Remove(e.File.FullName);
if (_unwire.Remove(e, out var unwire)) unwire();
}
if (closed is DiffTab d) _diffs.Remove(d);
if (closed is DocumentTab doc) _documents.Remove(doc.File.FullName);
Remove(closed);
closed.Dispose();
}

var remaining = strip.Where(t => !closing.Contains(t)).ToList();
if (tab is EditorTab closedEditor) EditorClosed?.Invoke(this, closedEditor);
if (remaining.Count == 0) ClearValue();
else if (wasActive) Value = remaining[Math.Min(before, remaining.Count - 1)];
TabsChanged?.Invoke(this, EventArgs.Empty);
if (remaining.Count == 0)
{
ClearValue();
return;
}
if (!wasActive) return;

Value = remaining[Math.Min(before, remaining.Count - 1)];
}

/// <summary>Close every open tab — used when switching workspace folders.</summary>
Expand All @@ -302,6 +369,7 @@ public void CloseAll()
tab.Dispose();
}
_byPath.Clear();
_unwire.Clear();
_diffs.Clear();
_documents.Clear();
TabsChanged?.Invoke(this, EventArgs.Empty);
Expand Down
Loading
Loading