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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ What follows is how TuiCode implements it today.
- `AlertView` (`Workbench/Controls/`) is the guide's message block. It wraps the message and reports the rows it needs (`Lines`); the dialog grows by that many and moves its buttons up (`SubmitReviewView.Alert`). `AlertSeverity.Error` draws it in the theme's `Error` scheme, `Info` in `Accent`. `sr`, `gs` and `cc` use it — `PathPromptView`, `RevisionPickerView` and `PullRequestPickerView` still have the one-row `Label` (#208).
- Text goes in a `VerbatimLabel` (Abstractions), never a plain `Label`, which takes the first `_` as a hotkey marker and hides it (#549). `AlertView` turns the hotkey off the same way. `VerbatimLabelTests` fails on any `new Label` in our assemblies.
- `LoadStateView` (from the library) is the guide's **Loading and empty** rule: `opr`, `sr`, `ctr` and the Review pane open at once and say loading, empty or failed where their content goes, never on the status bar (#525). `R` retries a failed load through a scoped command enabled only while it's failed, so it still types into the filter otherwise. `LoadStateViews.ShowLoadFailed` cuts the message to one row, since the library doesn't wrap it, and leaves `R retry` off outside a git repository, which the library can only do by showing it as empty (dimmed).
- **Pickers and prompts** (#572): Open recent folder, *Definitions of*, Choose grammar are the library's `PickerDialog`, and Go to line and the language server dialog are `AppDialog`s, all shown with `HostedDialog.Show`, which pushes their keys on our scope stack and answers their `F1` with a `KeysDialog` of their keys, hosted beside them. `PickerList` reads the list `PickerDialog` keeps private. Open worktree, Open pull request, Compare to revision and Go to symbol still have their own views and hint rows until the library can host them. An `OptionSelector` calls `SelectOnArrows()`, so the arrows choose and one `Tab` leaves it.
- `InputView` (`Workbench/Controls/`) is the guide's bordered multi-line field, used by `sr` and `cc`. It draws its box heavy while it has focus and single while it hasn't (#223), and leaves where typing goes to the terminal cursor, as the editor does — it paints no caret of its own, so nothing covers the character the cursor is on. A dialog hosting one is `InputView.Frame` rows and columns taller for the box.

## AOT
Expand Down Expand Up @@ -403,7 +404,7 @@ What follows is how TuiCode implements it today.
- **Workspace state (#13)** — the files open per folder and the active one — persists to `~/.tui/TuiCode.workspaces.json` via `WorkspaceStateStore`: a JSON array, most recently used folder first, capped at `MaxFolders` (30). The launch folder always wins (like `code .`); `Workbench.OpenFolder` (startup and the in-app Open) restores that folder's tabs, skipping files that are gone. Saves happen on every `ActiveTabChanged` (open, close, switch), so a crash loses nothing. `Workbench` nulls its folder while switching and on dispose so closing the old tabs isn't saved as that folder's state.
- **Where you were in each file (#502)** persists to `~/.tui/TuiCode.positions.json` via `FilePositionStore`, keyed by full path across every folder, most recently used first, capped at `MaxFiles` (300); paths inside `.git` (`FilePaths.IsInGitDir`) are never kept. `Workbench` records a tab's `EditorTab.ViewPosition` (cursor, plus the file line at the top) as it closes (`EditorGroups.EditorClosing`), every tab's before a folder switch and on dispose, never per keystroke. A file's tab gets it back on `EditorGroups.EditorOpening`, before it's added, so anything that opens at a place (a Find result, a jump, `file:42`) just moves the cursor afterwards and wins. The scroll waits for the tab's first draw, since TG doesn't size the content to the text until then, and an explicit `MoveCursor`, `Select` or `RevealLines` before that drops it.
- **Unsaved edits survive TuiCode ending unexpectedly (#579).** `UnsavedEdits` (`Workspace/`) keeps each dirty tab's text, `ViewPosition` and `OnDisk` snapshot in `RecoveryStore`, `~/.tui/recovery/<path hash>-<pid>.json` (`0600`, written to a temp file and renamed), `UnsavedEdits.Delay` (2 s) after its last edit. Buffers are read on the UI thread and the writes queued in order on a background task. The copy goes when the tab is clean again (save, undo back, reload), closes (every in-app close of a dirty tab has asked or been asked for: Don't save, delete), on a folder switch, and on *Don't save* at quit (`DiscardAll`), but not on `Workbench.Dispose`, which flushes instead, since a crash out of the loop ends there too. `SIGHUP`/`SIGTERM` flush through `WorkbenchHost.KeepUnsavedEdits` and then take their default course. A file opens with a copy (`EditorGroup.Recovery`) when the process that wrote it has gone (`RecoveryOwner`: pid plus start time), as `EditorTab.Recovered`: dirty, the old snapshot as `OnDisk`, so `⚠` or `⊘` and #267's prompt follow, and undo starting there; it skips the saved position (#502), and `RestoreOpenFiles` brings back a deleted file that has one. The new owner writes its own copy before forgetting the old. Paths in `.git` are kept, unlike positions. The status bar counts the files a loop iteration recovered.
- **Open recent folder (#357)** (`or`, no default key) lists `WorkspaceStateStore.Folders()` as `RecentFolderPickerView`, less the folder open now and any that no longer exist; it only reads the file, so dead entries are hidden, not pruned. `RecentFolderList` (pure) builds the name + `~`-shortened parent rows and filters them. Enter is `Workbench.OpenFolder`.
- **Open recent folder (#357)** (`or`, no default key) lists `WorkspaceStateStore.Folders()` as `RecentFolderPickerView`, less the folder open now and any that no longer exist; it only reads the file, so dead entries are hidden, not pruned. `RecentFolderList` (pure) builds the name + `~`-shortened parent rows; the picker filters on both. Enter is `Workbench.OpenFolder`.
- **Open file path (#358)** (`opa`, no default key; `op` would shadow `opr`) is a `PathPromptView` for a folder to open by its full path; `FolderPath.Expand` turns a leading `~` into the home folder and drops a trailing separator. A relative path, a missing folder or a file is `No such folder: <path>` in the prompt, which stays open; Enter otherwise is `Workbench.OpenFolder`.
- Don't reach into `TuiConfigurationBuilder` / `ThemeManager` from feature code — go through `ISettingsService`. Tests use `InMemorySettingsService`.
- Tests that mutate `ThemeManager.Theme` (e.g. `DefaultSettingsServiceTests`) derive from `StaticConfigurationTest`, which snapshots and restores it. Keybindings are instance state on the service, so keybinding-only tests need neither.
Expand Down
18 changes: 0 additions & 18 deletions src/TuiCode.Abstractions/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ public static class CommandIds
public const string ActionsFocusSearch = "actions.action.focusSearch";

public const string GoToLine = "workbench.action.goToLine";
public const string GoToLineConfirm = "goToLine.action.confirm";
public const string GoToLineCancel = "goToLine.action.cancel";

public const string GoToSymbol = "workbench.action.goToSymbol";
public const string GoToMatchingBracket = "workbench.action.goToMatchingBracket";
Expand All @@ -91,12 +89,6 @@ public static class CommandIds
public const string SymbolPickerPageDown = "symbolPicker.action.pageDown";

public const string GoToDefinition = "workbench.action.goToDefinition";
public const string DefinitionPickerConfirm = "definitionPicker.action.confirm";
public const string DefinitionPickerCancel = "definitionPicker.action.cancel";
public const string DefinitionPickerUp = "definitionPicker.action.up";
public const string DefinitionPickerDown = "definitionPicker.action.down";
public const string DefinitionPickerPageUp = "definitionPicker.action.pageUp";
public const string DefinitionPickerPageDown = "definitionPicker.action.pageDown";
public const string FindUsages = "workbench.action.findUsages";
public const string AiAssistance = "workbench.action.aiAssistance";
public const string AssistantSend = "assistant.action.send";
Expand Down Expand Up @@ -144,12 +136,6 @@ public static class CommandIds
public const string PullRequestPickerPageDown = "pullRequestPicker.action.pageDown";
public const string PullRequestPickerRetry = "pullRequestPicker.action.retry";

public const string RecentFolderPickerConfirm = "recentFolderPicker.action.confirm";
public const string RecentFolderPickerCancel = "recentFolderPicker.action.cancel";
public const string RecentFolderPickerUp = "recentFolderPicker.action.up";
public const string RecentFolderPickerDown = "recentFolderPicker.action.down";
public const string RecentFolderPickerPageUp = "recentFolderPicker.action.pageUp";
public const string RecentFolderPickerPageDown = "recentFolderPicker.action.pageDown";

public const string WorktreePickerConfirm = "worktreePicker.action.confirm";
public const string WorktreePickerCancel = "worktreePicker.action.cancel";
Expand Down Expand Up @@ -198,11 +184,7 @@ public static class CommandIds
public const string ToggleWordWrap = "editor.action.toggleWordWrap";

public const string ChangeGrammar = "workbench.action.changeGrammar";
public const string GrammarPickerConfirm = "grammarPicker.action.confirm";
public const string GrammarPickerCancel = "grammarPicker.action.cancel";

public const string LanguageServerSave = "languageServer.action.save";
public const string LanguageServerCancel = "languageServer.action.cancel";

public static string FocusEditorByIndex(int oneBasedIndex) =>
$"workbench.action.focusEditor{oneBasedIndex}";
Expand Down
50 changes: 50 additions & 0 deletions src/TuiCode.Workbench/Controls/HostedDialog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using MentalDesk.Tui.Dialogs;
using MentalDesk.Tui.Keys;
using TuiCode.Abstractions;
using TuiCode.Workbench.Services;

namespace TuiCode.Workbench.Controls;

/// <summary>A library <see cref="AppDialog"/> added to <c>host</c> with its keys on our scope stack, and <c>F1</c> showing its keys.</summary>
internal static class HostedDialog
{
/// <summary><paramref name="closed"/> runs once the dialog has left <paramref name="host"/>, before it's disposed.</summary>
public static void Show(View host, IInputScopeStack scopes, AppDialog dialog, Action closed)
{
var scope = new LibraryScope(dialog.Scope);
dialog.KeysRequested += (_, sheet) => ShowKeys(host, scopes, dialog, sheet);
dialog.Closed += (_, _) =>
{
scopes.Pop(scope);
host.Remove(dialog);
closed();
dialog.Dispose();
};
host.Add(dialog);
scopes.Push(scope);
}

private static void ShowKeys(View host, IInputScopeStack scopes, AppDialog dialog, KeySheet sheet)
{
var focused = dialog.MostFocused;
var caret = (focused as TextField)?.InsertionPoint;
var keys = new KeysDialog(sheet, host.Viewport.Size);
var scope = new LibraryScope(keys.Scope);
keys.Closed += (_, _) =>
{
scopes.Pop(scope);
host.Remove(keys);
keys.Dispose();
focused?.SetFocus();
// A TextField selects all as it takes focus, so the next key would replace what was typed.
if (focused is TextField field && caret is { } at)
{
field.ClearAllSelection();
field.InsertionPoint = at;
}
};
host.Add(keys);
scopes.Push(scope);
keys.SetFocus();
}
}
21 changes: 21 additions & 0 deletions src/TuiCode.Workbench/Controls/PickerList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using MentalDesk.Tui.Dialogs;

namespace TuiCode.Workbench.Controls;

/// <summary>What <see cref="PickerDialog{T}"/> doesn't expose of its list.</summary>
internal static class PickerList
{
private const int Chrome = 4;

/// <summary>The columns a row has in a picker <paramref name="dialogWidth"/> wide.</summary>
public static int Width(int dialogWidth) => dialogWidth - Chrome;

public static int? SelectedIndex<T>(this PickerDialog<T> picker) where T : class =>
List(picker).SelectedItem;

public static void Select<T>(this PickerDialog<T> picker, int index) where T : class =>
List(picker).SelectedItem = index;

private static ListView List<T>(PickerDialog<T> picker) where T : class =>
picker.SubViews.OfType<ListView>().Single();
}
91 changes: 21 additions & 70 deletions src/TuiCode.Workbench/Grammars/GrammarPickerView.cs
Original file line number Diff line number Diff line change
@@ -1,84 +1,35 @@
using TuiCode.Abstractions;
using MentalDesk.Tui.Dialogs;
using TuiCode.Syntax;
using TuiCode.Workbench.Services;
using TuiCode.Workbench.Controls;

namespace TuiCode.Workbench.Grammars;

/// <summary>Modal grammar list with a filter box, shared by <c>cg</c> and the Grammars settings pane. The owner pushes <see cref="Scope"/> and removes it on <see cref="Closed"/>.</summary>
public sealed class GrammarPickerView : Window
{
private readonly TextField _search;
private readonly ListView _list;
private readonly IReadOnlyList<Row> _allRows;
private readonly ICommandService _scopeCommands = new CommandService();
private List<Row> _visibleRows = [];

public IKeybindingService Scope { get; }

/// <summary>Raised with the chosen grammar (null for Plain Text).</summary>
public event EventHandler<SyntaxLanguage?>? Chosen;

/// <summary>Raised when the picker should be removed: after a choice, or on Esc.</summary>
public event EventHandler? Closed;
/// <summary>A row of the grammar picker: null <see cref="Grammar"/> is Plain Text.</summary>
internal sealed record GrammarRow(SyntaxLanguage? Grammar, string Name, bool IsCurrent);

/// <summary>Modal grammar list with a filter box, shared by <c>cg</c> and the Grammars settings pane.</summary>
internal sealed class GrammarPickerView : PickerDialog<GrammarRow>
{
public GrammarPickerView(IEnumerable<SyntaxLanguage> grammars, string title, SyntaxLanguage? current)
: this(title, Rows(grammars, current))
{
Title = title;
BorderStyle = LineStyle.Single;
X = Pos.Center();
Y = Pos.Center();
Width = 60;
Height = 22;
CanFocus = true;

_search = new TextField { X = 1, Y = 0, Width = Dim.Fill(1), Height = 1 };
_search.TextChanged += (_, _) => RebuildVisible();
_search.MouseEvent += (_, _) => _search.SetFocus();

_list = new ListView { X = 1, Y = Pos.Bottom(_search) + 1, Width = Dim.Fill(1), Height = Dim.Fill(1) };
_list.MouseEvent += (_, _) => _list.SetFocus();

Add(_search, _list);

_allRows =
[
new Row(null, Workbench.PlainTextName, current is null),
.. grammars
.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => new Row(g, g.Name, g.Id == current?.Id)),
];

Scope = new KeybindingService(_scopeCommands);
_scopeCommands.Register(CommandIds.GrammarPickerCancel, () => Closed?.Invoke(this, EventArgs.Empty));
_scopeCommands.Register(CommandIds.GrammarPickerConfirm, ChooseSelected);
Scope.Bind("Esc", CommandIds.GrammarPickerCancel);
Scope.Bind("Enter", CommandIds.GrammarPickerConfirm);

RebuildVisible();
}

public bool FocusSearch() => _search.SetFocus();

private void RebuildVisible()
private GrammarPickerView(string title, IReadOnlyList<GrammarRow> rows)
: base(title, 60, 20, rows, r => r.Grammar is null ? r.Name : $"{r.Name} {r.Grammar.Id}", "Choose",
r => (r.IsCurrent ? "● " : " ") + r.Name)
{
var query = _search.Text ?? "";
_visibleRows = _allRows
.Where(r => r.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
|| (r.Grammar?.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
.ToList();

_list.Source = new ListWrapper<string>(new(_visibleRows.Select(r => (r.IsCurrent ? "● " : " ") + r.Name)));
var current = string.IsNullOrEmpty(query) ? _visibleRows.FindIndex(r => r.IsCurrent) : -1;
_list.SelectedItem = _visibleRows.Count == 0 ? null : Math.Max(current, 0);
this.Select(Math.Max(0, rows.ToList().FindIndex(r => r.IsCurrent)));
}

private void ChooseSelected()
{
var i = _list.SelectedItem ?? -1;
if (i < 0 || i >= _visibleRows.Count) return;
Chosen?.Invoke(this, _visibleRows[i].Grammar);
Closed?.Invoke(this, EventArgs.Empty);
}
/// <summary>The grammar chosen, null for Plain Text; only meaningful once <see cref="AppDialog.Confirmed"/>.</summary>
public SyntaxLanguage? Grammar => Chosen?.Grammar;

private sealed record Row(SyntaxLanguage? Grammar, string Name, bool IsCurrent);
private static IReadOnlyList<GrammarRow> Rows(IEnumerable<SyntaxLanguage> grammars, SyntaxLanguage? current) =>
[
new GrammarRow(null, Workbench.PlainTextName, current is null),
.. grammars
.OrderBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => new GrammarRow(g, g.Name, g.Id == current?.Id)),
];
}
Loading
Loading