diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..bb04e25
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,32 @@
+# Repository Guidelines
+
+## Project Structure & Module Organization
+
+SpiceEdit is a Go terminal editor module at `github.com/cloudmanic/spice-edit`; the CLI entry point is `main.go` and the binary is `spiceedit`. Core packages live under `internal/`: `app` owns the event loop and rendering, `editor` owns buffers/tabs/editing behavior, `filetree` manages the sidebar tree, and supporting packages cover clipboard, formatting, icons, config, theme, finder, and versioning. Tests sit beside source files as `*_test.go`. Website assets and docs live in `website/` as a Hugo + Tailwind site. Release packaging includes `Formula/spice-edit.rb`, `install.sh`, and samples under `samples/`.
+
+## Build, Test, and Development Commands
+
+- `make run`: run the editor in the current directory with `go run .`.
+- `make build`: compile `./bin/spiceedit`.
+- `make build-linux`: cross-compile a static `linux/amd64` binary.
+- `make test`: run `go test -race ./...`; use before PRs.
+- `make test-short`: quick `go test -short ./...` loop while iterating.
+- `make coverage`: write `coverage.out` and `coverage.html`.
+- `make tidy`: sync `go.mod` and `go.sum`.
+- `make site-install`, `make site-dev`, `make site-build`: manage website deps, local Hugo, and production builds.
+
+## Coding Style & Naming Conventions
+
+Use `gofmt`/`go test` defaults and idiomatic Go names: exported identifiers in `CamelCase`, unexported in `camelCase`, package names short and lowercase. New Go source files should follow the existing header block style. Keep short doc comments above functions, including private helpers, explaining intent. Avoid adding `Ctrl+` shortcuts; editor actions must stay reachable from the main `≡` menu because SSH/tmux workflows may swallow shortcuts or right-click events.
+
+## Testing Guidelines
+
+Every non-trivial source file should have a same-package test file, for example `internal/editor/buffer.go` and `internal/editor/buffer_test.go`. Add regression tests for bug fixes and cover happy paths and obvious failures. Use `t.TempDir()` for filesystem state. For drawing tests, use `tcell.NewSimulationScreen("UTF-8")` and assert screen contents.
+
+## Commit & Pull Request Guidelines
+
+Recent commits use concise, imperative summaries, often with PR numbers, such as `Mute dotfiles in tree + per-tab Nerd Font icons (#32)`. Release automation uses `[skip ci]`; preserve that marker when editing generated release commits or workflows. PRs should describe behavior changes, mention tests run, link issues, and include screenshots or terminal captures for UI/website changes.
+
+## Security & Configuration Tips
+
+Format-on-save commands are project config and require trust prompts; do not bypass that flow. Keep generated artifacts (`bin/`, `coverage.out`, `coverage.html`, `website/public/`, built CSS) out of normal feature commits unless the release or website workflow explicitly requires them.
diff --git a/README.md b/README.md
index 49687db..ea68654 100644
--- a/README.md
+++ b/README.md
@@ -178,20 +178,21 @@ SpiceEdit deliberately avoids `Ctrl+`-style shortcuts (they fight `tmux`,
real terminal). Instead, **`Esc` is the leader key**: tap `Esc`, then
within half a second tap one of the letters below.
-| Combo | Action |
-| ----------- | --------------- |
-| `Esc Esc` | Open ≡ menu |
-| `Esc s` | Save |
-| `Esc u` | Undo |
-| `Esc r` | Redo |
-| `Esc w` | Close tab |
-| `Esc q` | Quit |
-| `Esc n` | New file |
-| `Esc t` | Toggle sidebar |
-| `Esc f` | Find in file |
+| Combo | Action |
+| ----------- | -------------------- |
+| `Esc Esc` | Open ≡ menu |
+| `Esc s` | Save |
+| `Esc u` | Undo |
+| `Esc r` | Redo |
+| `Esc w` | Close tab |
+| `Esc q` | Quit |
+| `Esc n` | New file |
+| `Esc t` | Toggle sidebar |
+| `Esc /` | Toggle line comment |
+| `Esc f` | Find in file |
| `Esc p` | Find file in project |
-A lone `Esc` is harmless — if you don't follow it with a bound letter
+A lone `Esc` is harmless — if you don't follow it with a bound key
within the window, your next keystroke goes to the editor as normal,
so accidental `Esc` taps never swallow a real character.
diff --git a/internal/app/app.go b/internal/app/app.go
index d14b77e..594ffdf 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -46,10 +46,10 @@ const (
minEditorAfterDrag = 40
minWidth = 50
minHeight = 24
- statusFlashFor = 3 * time.Second
- doubleClickMs = 500 * time.Millisecond
- doubleEscMs = 500 * time.Millisecond
- wheelLines = 3
+ statusFlashFor = 3 * time.Second
+ doubleClickMs = 500 * time.Millisecond
+ doubleEscMs = 500 * time.Millisecond
+ wheelLines = 3
// treeRefreshInterval is how often the background goroutine kicks off
// a file-tree reload so the sidebar stays in sync with on-disk changes
@@ -193,6 +193,7 @@ func builtinMenuGroups() [][]menuItemDef {
{label: "Copy selection", action: (*App).menuCopy, enabled: (*App).hasSelection},
{label: "Cut selection", action: (*App).menuCut, enabled: (*App).hasSelection},
{label: "Paste", action: (*App).menuPaste, enabled: (*App).hasClipboard},
+ {label: "Toggle line comment", action: (*App).menuToggleLineComment, enabled: (*App).hasCommentableTab},
},
// View toggle
{
@@ -230,7 +231,7 @@ func (a *App) menuLayout() (items []menuItemDef, dividers []int, modalHeight int
// the menu; if a $FILE-dependent command is invoked with
// no tab open it'll fail with a real error and our info
// modal surfaces it. Better that than getting the
- // heuristic wrong half the time.
+ // heuristic wrong half the time.
ca = append(ca, menuItemDef{
label: a.customActions[i].Label,
action: func(app *App) { app.runCustomAction(i) },
@@ -299,9 +300,9 @@ type App struct {
lastClick clickRecord
lastTabRects []tabRect
- menuOpen bool
- hoveredMenuRow int // index into menuItems of the row under the mouse, or -1.
- lastEscape time.Time // timestamp of the previous Esc press, for double-tap detection.
+ menuOpen bool
+ hoveredMenuRow int // index into menuItems of the row under the mouse, or -1.
+ lastEscape time.Time // timestamp of the previous Esc press, for double-tap detection.
// Prompt modal — single-line text input with OK / Cancel. Used by
// Rename and New File. See modals.go for render + event handling.
@@ -1704,6 +1705,17 @@ func (a *App) hasSelection() bool {
return t != nil && t.HasSelection()
}
+// hasCommentableTab reports whether the active tab is editable text with a
+// known single-line comment marker.
+func (a *App) hasCommentableTab() bool {
+ t := a.activeTabPtr()
+ if t == nil || t.IsImage() {
+ return false
+ }
+ _, ok := editor.LineCommentPrefix(t.Path)
+ return ok
+}
+
// hasClipboard reports whether the editor's internal clipboard has content
// to paste.
func (a *App) hasClipboard() bool { return a.clipBuf != "" }
@@ -1891,6 +1903,25 @@ func (a *App) menuPaste() {
a.pasteClipboard()
}
+// menuToggleLineComment comments or uncomments the active line selection.
+func (a *App) menuToggleLineComment() {
+ a.closeMenu()
+ tab := a.activeTabPtr()
+ if tab == nil || tab.IsImage() {
+ return
+ }
+ changed, ok := tab.ToggleLineComment()
+ if !ok {
+ a.flash("No line comment syntax for this file")
+ return
+ }
+ if !changed {
+ a.flash("No non-blank lines to comment")
+ return
+ }
+ a.flash("Toggled line comment")
+}
+
// menuRefreshTree forces an immediate sidebar reload. Currently unwired
// from the menu — the 10s background poller covers the common case — but
// the method is kept so re-adding the menu row (see menuItems) only
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index 15fcdbe..192ca4a 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -231,10 +231,10 @@ func TestResizeSidebar_TinyWindow(t *testing.T) {
// TestDetectLangLabel covers the language label helper's three cases.
func TestDetectLangLabel(t *testing.T) {
cases := map[string]string{
- "": "text",
- "foo.go": "go",
- "foo": "text",
- "path/to/x.py": "py",
+ "": "text",
+ "foo.go": "go",
+ "foo": "text",
+ "path/to/x.py": "py",
"archive.tar.gz": "gz",
}
for in, want := range cases {
@@ -396,8 +396,8 @@ func TestCloseTab_OutOfRange(t *testing.T) {
a.requestCloseTab(99)
}
-// TestHasTab_Predicates covers the four "is X available?" checks used to
-// dim menu rows.
+// TestHasTab_Predicates covers the "is X available?" checks used to dim menu
+// rows.
func TestHasTab_Predicates(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "f.txt")
@@ -405,8 +405,8 @@ func TestHasTab_Predicates(t *testing.T) {
t.Fatalf("seed: %v", err)
}
a := newTestApp(t, dir)
- if a.hasTab() || a.hasSavableTab() || a.hasSelection() || a.hasClipboard() {
- t.Fatal("fresh app should have no tab/selection/clipboard")
+ if a.hasTab() || a.hasSavableTab() || a.hasSelection() || a.hasClipboard() || a.hasCommentableTab() {
+ t.Fatal("fresh app should have no tab/selection/clipboard/comment action")
}
a.openFile(target)
@@ -416,6 +416,9 @@ func TestHasTab_Predicates(t *testing.T) {
if a.hasSelection() {
t.Fatal("no selection on a fresh tab")
}
+ if a.hasCommentableTab() {
+ t.Fatal(".txt should not expose the line-comment action")
+ }
// Make a synthetic selection.
tab := a.activeTabPtr()
@@ -431,6 +434,31 @@ func TestHasTab_Predicates(t *testing.T) {
}
}
+// TestHasCommentableTab_Predicate checks that line-comment actions only enable
+// on editable text tabs with known comment syntax.
+func TestHasCommentableTab_Predicate(t *testing.T) {
+ dir := t.TempDir()
+ goFile := filepath.Join(dir, "main.go")
+ htmlFile := filepath.Join(dir, "index.html")
+ if err := os.WriteFile(goFile, []byte("package main"), 0644); err != nil {
+ t.Fatalf("seed go: %v", err)
+ }
+ if err := os.WriteFile(htmlFile, []byte(""), 0644); err != nil {
+ t.Fatalf("seed html: %v", err)
+ }
+ a := newTestApp(t, dir)
+
+ a.openFile(goFile)
+ if !a.hasCommentableTab() {
+ t.Fatal(".go tab should expose the line-comment action")
+ }
+
+ a.openFile(htmlFile)
+ if a.hasCommentableTab() {
+ t.Fatal(".html tab should not expose the line-comment action")
+ }
+}
+
// TestSidebarToggleLabel flips between Show/Hide based on sidebarShown.
func TestSidebarToggleLabel(t *testing.T) {
a := newTestApp(t, t.TempDir())
@@ -623,6 +651,48 @@ func TestMenuToggleSidebar(t *testing.T) {
}
}
+// TestMenuToggleLineComment runs the menu action against the active tab so the
+// app layer and editor-layer primitive stay wired together.
+func TestMenuToggleLineComment(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "main.go")
+ if err := os.WriteFile(target, []byte("one\ntwo"), 0644); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ a := newTestApp(t, dir)
+ a.openFile(target)
+
+ a.menuToggleLineComment()
+
+ if got := a.activeTabPtr().Buffer.String(); got != "// one\ntwo" {
+ t.Fatalf("buffer = %q, want current line commented", got)
+ }
+ if a.statusMsg != "Toggled line comment" {
+ t.Fatalf("statusMsg = %q", a.statusMsg)
+ }
+}
+
+// TestMenuToggleLineComment_Unsupported flashes a clear no-op instead of
+// guessing at block-comment-only formats.
+func TestMenuToggleLineComment_Unsupported(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "index.html")
+ if err := os.WriteFile(target, []byte(""), 0644); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ a := newTestApp(t, dir)
+ a.openFile(target)
+
+ a.menuToggleLineComment()
+
+ if got := a.activeTabPtr().Buffer.String(); got != "" {
+ t.Fatalf("unsupported buffer changed to %q", got)
+ }
+ if a.statusMsg != "No line comment syntax for this file" {
+ t.Fatalf("statusMsg = %q", a.statusMsg)
+ }
+}
+
// TestTabBarClick_OpensMenu clicks the ≡ button cell and verifies the menu
// opens.
func TestTabBarClick_OpensMenu(t *testing.T) {
@@ -999,9 +1069,9 @@ func TestScrollAt(t *testing.T) {
}
a := newTestApp(t, dir)
a.openFile(target)
- a.scrollAt(1, 5, 1) // sidebar
- a.scrollAt(60, 5, 1) // editor
- a.scrollAt(60, a.height-1, 1) // status bar (no-op-ish)
+ a.scrollAt(1, 5, 1) // sidebar
+ a.scrollAt(60, 5, 1) // editor
+ a.scrollAt(60, a.height-1, 1) // status bar (no-op-ish)
}
// TestSidebarClick_File opens a file when a file row is clicked.
@@ -1281,9 +1351,20 @@ func TestHandleMenuMouse_ClicksRowAndOutside(t *testing.T) {
a := newTestApp(t, t.TempDir())
a.openMenu()
mx, my, _, _ := a.menuModalRect()
- // Click on the toggle row (relY=26) — flips the sidebar.
+ // Click on the sidebar toggle row — flips the sidebar.
+ items, _, _ := a.menuLayout()
+ toggleRelY := -1
+ for _, item := range items {
+ if item.labelFor != nil && item.labelFor(a) == "Hide file explorer" {
+ toggleRelY = item.relY
+ break
+ }
+ }
+ if toggleRelY < 0 {
+ t.Fatal("sidebar toggle row not found")
+ }
before := a.sidebarShown
- a.handleMenuMouse(mx+5, my+26, tcell.Button1)
+ a.handleMenuMouse(mx+5, my+toggleRelY, tcell.Button1)
if a.sidebarShown == before {
t.Fatal("expected toggle to fire")
}
@@ -1408,7 +1489,7 @@ func TestDrawStatusBar_OmitsBranchWhenEmpty(t *testing.T) {
}
// TestMenuLayout_NoCustomActions pins down the baseline geometry: with
-// zero custom actions the modal still has six built-in groups and the
+// zero custom actions the modal still has seven built-in groups and the
// height matches the expected layout total. Catches accidental
// off-by-one regressions when someone tweaks the layout helper.
func TestMenuLayout_NoCustomActions(t *testing.T) {
@@ -1416,13 +1497,13 @@ func TestMenuLayout_NoCustomActions(t *testing.T) {
a.customActions = nil
items, dividers, h := a.menuLayout()
- if h != 30 {
- t.Errorf("modalHeight = %d, want 30", h)
+ if h != 31 {
+ t.Errorf("modalHeight = %d, want 31", h)
}
- if got := len(items); got != 20 {
- t.Errorf("item count = %d, want 20 built-ins", got)
+ if got := len(items); got != 21 {
+ t.Errorf("item count = %d, want 21 built-ins", got)
}
- wantDiv := []int{2, 6, 10, 13, 21, 25, 27}
+ wantDiv := []int{2, 6, 10, 13, 21, 26, 28}
if len(dividers) != len(wantDiv) {
t.Fatalf("dividers = %v, want %v", dividers, wantDiv)
}
@@ -1433,6 +1514,42 @@ func TestMenuLayout_NoCustomActions(t *testing.T) {
}
}
+// TestMenuLayout_ToggleLineCommentRow ensures the comment action is present
+// and uses the same enablement predicate as the direct app method.
+func TestMenuLayout_ToggleLineCommentRow(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "main.go")
+ if err := os.WriteFile(target, []byte("package main"), 0644); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ a := newTestApp(t, dir)
+
+ item := menuItemByLabel(t, a, "Toggle line comment")
+ if item.enabled(a) {
+ t.Fatal("comment row should be disabled without an active tab")
+ }
+
+ a.openFile(target)
+ item = menuItemByLabel(t, a, "Toggle line comment")
+ if !item.enabled(a) {
+ t.Fatal("comment row should be enabled for a .go tab")
+ }
+}
+
+// menuItemByLabel finds a menu row by static label for tests that care about
+// one action without hard-coding its row index.
+func menuItemByLabel(t *testing.T, a *App, label string) menuItemDef {
+ t.Helper()
+ items, _, _ := a.menuLayout()
+ for _, item := range items {
+ if item.label == label {
+ return item
+ }
+ }
+ t.Fatalf("menu item %q not found", label)
+ return menuItemDef{}
+}
+
// TestMenuLayout_WithCustomActions checks the splice-before-Quit
// behaviour: two custom actions land as their own group sitting
// directly above the Quit row, with a divider on each side. Modal
@@ -1445,8 +1562,8 @@ func TestMenuLayout_WithCustomActions(t *testing.T) {
}
items, _, h := a.menuLayout()
- if h != 33 { // 30 + 2 items + 1 divider
- t.Errorf("modalHeight = %d, want 33", h)
+ if h != 34 { // 31 + 2 items + 1 divider
+ t.Errorf("modalHeight = %d, want 34", h)
}
// Custom actions should be the second-to-last and third-to-last
// rows, with Quit as the final row.
diff --git a/internal/app/leader.go b/internal/app/leader.go
index 4b5f7e9..dba3770 100644
--- a/internal/app/leader.go
+++ b/internal/app/leader.go
@@ -7,18 +7,18 @@
// leader.go defines the editor's Esc-leader hotkey table. Esc-Esc still opens
// the action menu (handled in handleKey); the bindings here handle the
-// "Esc, then a single letter within doubleEscMs" sequences for common
+// "Esc, then one rune within doubleEscMs" sequences for common
// actions. We deliberately avoid Ctrl-key shortcuts because they fight
// tmux/zellij prefixes and the terminal's own bindings — Esc is the only
// modifier we trust over SSH.
package app
-// leaderBinding is one Esc-leader entry: the trigger rune (lowercase ASCII)
-// and the App method that fires when the user presses Esc, in quick
-// succession. Each method already handles its own preconditions — calling
-// menuUndo with no active tab, for example, is a safe no-op — so the leader
-// dispatch doesn't need to re-check enable predicates.
+// leaderBinding is one Esc-leader entry: the trigger rune and the App method
+// that fires when the user presses Esc, in quick succession. Each method
+// already handles its own preconditions — calling menuUndo with no active tab,
+// for example, is a safe no-op — so the leader dispatch doesn't need to
+// re-check enable predicates.
type leaderBinding struct {
key rune
action func(*App)
@@ -26,9 +26,9 @@ type leaderBinding struct {
// leaderBindings is the editor's full Esc-leader table. The order is purely
// presentational: tests iterate it to assert every binding fires, and a
-// future help screen can render the table directly. Letters are chosen to
-// be mnemonic and avoid collisions — capital letters are reserved in case
-// we ever want a "shift-flavored" variant for destructive twins.
+// future help screen can render the table directly. Letter bindings are
+// chosen to be mnemonic and avoid collisions; punctuation bindings mirror
+// familiar editor gestures where they make sense.
//
// Intentionally not bound:
// - c / x / v (clipboard) — the host terminal's Cmd+C/V already covers
@@ -44,6 +44,7 @@ func leaderBindings() []leaderBinding {
{'q', (*App).menuQuit},
{'n', (*App).menuNewFile},
{'t', (*App).menuToggleSidebar},
+ {'/', (*App).menuToggleLineComment},
{'f', (*App).openFind},
{'p', (*App).openFinder},
}
diff --git a/internal/app/leader_test.go b/internal/app/leader_test.go
index cb6d159..5ee5046 100644
--- a/internal/app/leader_test.go
+++ b/internal/app/leader_test.go
@@ -99,6 +99,25 @@ func TestHandleKey_LeaderToggleSidebar(t *testing.T) {
}
}
+// TestHandleKey_LeaderToggleLineComment binds Esc-/ to the same action menu
+// path, giving keyboard users a fast toggle without adding Ctrl shortcuts.
+func TestHandleKey_LeaderToggleLineComment(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "main.go")
+ if err := os.WriteFile(target, []byte("one\ntwo"), 0644); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ a := newTestApp(t, dir)
+ a.openFile(target)
+
+ a.handleKey(keyEv(tcell.KeyEsc, 0))
+ a.handleKey(keyEv(tcell.KeyRune, '/'))
+
+ if got := a.activeTabPtr().Buffer.String(); got != "// one\ntwo" {
+ t.Fatalf("Esc-/ should comment the cursor line, got %q", got)
+ }
+}
+
// TestHandleKey_LeaderQuit sets a.quit via Esc-q. We test this directly
// rather than through Run() so we don't have to drive the event loop —
// the quit flag is what Run() polls each tick.
diff --git a/internal/editor/comment.go b/internal/editor/comment.go
new file mode 100644
index 0000000..ae21088
--- /dev/null
+++ b/internal/editor/comment.go
@@ -0,0 +1,211 @@
+// =============================================================================
+// File: internal/editor/comment.go
+// Author: Spicer Matthews
+// Created: 2026-05-14
+// Copyright: 2026 Cloudmanic, LLC. All rights reserved.
+// =============================================================================
+
+package editor
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// lineCommentByExt maps common source file extensions to their single-line
+// comment marker. Block-comment-only languages are intentionally omitted.
+var lineCommentByExt = map[string]string{
+ ".adb": "--",
+ ".ads": "--",
+ ".bash": "#",
+ ".c": "//",
+ ".cc": "//",
+ ".clj": ";",
+ ".cljs": ";",
+ ".cmake": "#",
+ ".conf": "#",
+ ".cpp": "//",
+ ".cs": "//",
+ ".csh": "#",
+ ".cxx": "//",
+ ".dart": "//",
+ ".el": ";",
+ ".elm": "--",
+ ".erl": "%",
+ ".ex": "#",
+ ".exs": "#",
+ ".env": "#",
+ ".fish": "#",
+ ".go": "//",
+ ".h": "//",
+ ".hpp": "//",
+ ".hs": "--",
+ ".ini": ";",
+ ".java": "//",
+ ".jl": "#",
+ ".js": "//",
+ ".jsx": "//",
+ ".kt": "//",
+ ".kts": "//",
+ ".less": "//",
+ ".lua": "--",
+ ".mjs": "//",
+ ".mk": "#",
+ ".mm": "//",
+ ".php": "//",
+ ".pl": "#",
+ ".pm": "#",
+ ".ps1": "#",
+ ".py": "#",
+ ".r": "#",
+ ".rb": "#",
+ ".rs": "//",
+ ".sass": "//",
+ ".scala": "//",
+ ".scss": "//",
+ ".sh": "#",
+ ".sql": "--",
+ ".swift": "//",
+ ".toml": "#",
+ ".ts": "//",
+ ".tsx": "//",
+ ".vim": "\"",
+ ".yaml": "#",
+ ".yml": "#",
+ ".zsh": "#",
+ ".dockerfile": "#",
+ ".gitignore": "#",
+}
+
+// LineCommentPrefix returns the single-line comment marker for path. The
+// boolean is false for file types that do not have a safe line-comment syntax.
+func LineCommentPrefix(path string) (string, bool) {
+ base := strings.ToLower(filepath.Base(path))
+ switch base {
+ case "dockerfile", "containerfile", "makefile", "gnumakefile", "rakefile", "gemfile", "justfile":
+ return "#", true
+ case "cmakelists.txt":
+ return "#", true
+ }
+ if prefix, ok := lineCommentByExt[base]; ok {
+ return prefix, true
+ }
+ prefix, ok := lineCommentByExt[strings.ToLower(filepath.Ext(base))]
+ return prefix, ok
+}
+
+// ToggleLineComment comments or uncomments the selected lines. It returns
+// ok=false when the active file type has no known line-comment marker.
+func (t *Tab) ToggleLineComment() (changed bool, ok bool) {
+ if t == nil || t.IsImage() || t.Buffer == nil {
+ return false, false
+ }
+ prefix, ok := LineCommentPrefix(t.Path)
+ if !ok {
+ return false, false
+ }
+ start, end := t.commentLineRange()
+ if !hasNonBlankLine(t.Buffer.Lines, start, end) {
+ return false, true
+ }
+ uncomment := t.linesAreCommented(start, end, prefix)
+
+ t.pushUndo(undoGroupStructural)
+ for i := start; i <= end; i++ {
+ line := t.Buffer.Lines[i]
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ if uncomment {
+ t.Buffer.Lines[i] = uncommentLine(line, prefix)
+ continue
+ }
+ t.Buffer.Lines[i] = commentLine(line, prefix)
+ }
+ t.Cursor = t.Buffer.Clamp(t.Cursor)
+ t.Anchor = t.Buffer.Clamp(t.Anchor)
+ t.Dirty = true
+ t.StyleStale = true
+ t.cursorMoved = true
+ return true, true
+}
+
+// commentLineRange returns the inclusive line range touched by the current
+// selection, or the cursor line when there is no selection.
+func (t *Tab) commentLineRange() (int, int) {
+ if !t.HasSelection() {
+ line := t.Buffer.Clamp(t.Cursor).Line
+ return line, line
+ }
+ start, end := PosOrdered(t.Anchor, t.Cursor)
+ start = t.Buffer.Clamp(start)
+ end = t.Buffer.Clamp(end)
+ if end.Col == 0 && end.Line > start.Line {
+ end.Line--
+ }
+ return start.Line, end.Line
+}
+
+// linesAreCommented reports whether every non-blank line in the range already
+// starts with prefix, either at column zero or after indentation.
+func (t *Tab) linesAreCommented(start, end int, prefix string) bool {
+ for i := start; i <= end; i++ {
+ line := t.Buffer.Lines[i]
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ if !lineHasCommentPrefix(line, prefix) {
+ return false
+ }
+ }
+ return true
+}
+
+// hasNonBlankLine reports whether any line in the inclusive range has content.
+func hasNonBlankLine(lines []string, start, end int) bool {
+ for i := start; i <= end; i++ {
+ if strings.TrimSpace(lines[i]) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+// commentLine inserts prefix at column zero, leaving the line's existing
+// indentation untouched after the marker.
+func commentLine(line, prefix string) string {
+ return prefix + " " + line
+}
+
+// uncommentLine removes prefix, plus one following space if present, from
+// column zero or from after indentation for lines toggled by older builds.
+func uncommentLine(line, prefix string) string {
+ if strings.HasPrefix(line, prefix) {
+ rest := strings.TrimPrefix(line, prefix)
+ rest = strings.TrimPrefix(rest, " ")
+ return rest
+ }
+ indent, rest := splitIndent(line)
+ rest = strings.TrimPrefix(rest, prefix)
+ rest = strings.TrimPrefix(rest, " ")
+ return indent + rest
+}
+
+// lineHasCommentPrefix reports whether line starts with prefix at column zero
+// or after indentation.
+func lineHasCommentPrefix(line, prefix string) bool {
+ if strings.HasPrefix(line, prefix) {
+ return true
+ }
+ _, rest := splitIndent(line)
+ return strings.HasPrefix(rest, prefix)
+}
+
+// splitIndent separates leading horizontal whitespace from the rest of a line.
+func splitIndent(line string) (string, string) {
+ i := 0
+ for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
+ i++
+ }
+ return line[:i], line[i:]
+}
diff --git a/internal/editor/comment_test.go b/internal/editor/comment_test.go
new file mode 100644
index 0000000..19da433
--- /dev/null
+++ b/internal/editor/comment_test.go
@@ -0,0 +1,211 @@
+// =============================================================================
+// File: internal/editor/comment_test.go
+// Author: Spicer Matthews
+// Created: 2026-05-14
+// Copyright: 2026 Cloudmanic, LLC. All rights reserved.
+// =============================================================================
+
+package editor
+
+import "testing"
+
+// TestLineCommentPrefix_CommonExtensions pins the filename and extension
+// lookup used by the toggle action before it mutates a buffer.
+func TestLineCommentPrefix_CommonExtensions(t *testing.T) {
+ cases := []struct {
+ path string
+ want string
+ ok bool
+ }{
+ {"main.go", "//", true},
+ {"script.py", "#", true},
+ {"query.sql", "--", true},
+ {"config.ini", ";", true},
+ {"Dockerfile", "#", true},
+ {"index.html", "", false},
+ }
+ for _, c := range cases {
+ got, ok := LineCommentPrefix(c.path)
+ if got != c.want || ok != c.ok {
+ t.Fatalf("LineCommentPrefix(%q) = %q, %v; want %q, %v", c.path, got, ok, c.want, c.ok)
+ }
+ }
+}
+
+// TestToggleLineComment_CommentsSelectedLines checks the headline path:
+// every selected non-blank line gets a comment marker at column zero.
+func TestToggleLineComment_CommentsSelectedLines(t *testing.T) {
+ tab := commentTestTab("main.go", "package main\nfunc main() {\n\tprintln(\"x\")\n}\n")
+ tab.Anchor = Position{Line: 1, Col: 0}
+ tab.Cursor = Position{Line: 3, Col: 0}
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ want := "package main\n// func main() {\n// \tprintln(\"x\")\n}\n"
+ if got := tab.Buffer.String(); got != want {
+ t.Fatalf("buffer:\n%q\nwant:\n%q", got, want)
+ }
+ if !tab.Dirty || !tab.StyleStale {
+ t.Fatal("toggle should dirty the tab and invalidate highlighting")
+ }
+}
+
+// TestToggleLineComment_UncommentsWhenAllLinesCommented proves the toggle
+// flips direction only when every non-blank target line is already commented.
+func TestToggleLineComment_UncommentsWhenAllLinesCommented(t *testing.T) {
+ tab := commentTestTab("main.go", "// one\n// \ttwo\n")
+ tab.Anchor = Position{Line: 0, Col: 0}
+ tab.Cursor = Position{Line: 2, Col: 0}
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ want := "one\n\ttwo\n"
+ if got := tab.Buffer.String(); got != want {
+ t.Fatalf("buffer:\n%q\nwant:\n%q", got, want)
+ }
+}
+
+// TestToggleLineComment_UncommentsIndentedExistingComments keeps the toggle
+// tolerant of comments that already sit after indentation.
+func TestToggleLineComment_UncommentsIndentedExistingComments(t *testing.T) {
+ tab := commentTestTab("main.go", "\t// one\n // two\n")
+ tab.Anchor = Position{Line: 0, Col: 0}
+ tab.Cursor = Position{Line: 2, Col: 0}
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ want := "\tone\n two\n"
+ if got := tab.Buffer.String(); got != want {
+ t.Fatalf("buffer:\n%q\nwant:\n%q", got, want)
+ }
+}
+
+// TestToggleLineComment_MixedSelectionCommentsAllLines locks in the common
+// editor rule: a mixed selection comments every non-blank line.
+func TestToggleLineComment_MixedSelectionCommentsAllLines(t *testing.T) {
+ tab := commentTestTab("main.go", "// one\n\n two")
+ tab.SelectAll()
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ want := "// // one\n\n// two"
+ if got := tab.Buffer.String(); got != want {
+ t.Fatalf("buffer:\n%q\nwant:\n%q", got, want)
+ }
+}
+
+// TestToggleLineComment_SelectionEndingAtColumnZeroExcludesThatLine keeps
+// whole-line selections from unexpectedly changing the first untouched line.
+func TestToggleLineComment_SelectionEndingAtColumnZeroExcludesThatLine(t *testing.T) {
+ tab := commentTestTab("main.go", "one\ntwo\nthree")
+ tab.Anchor = Position{Line: 0, Col: 0}
+ tab.Cursor = Position{Line: 2, Col: 0}
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ want := "// one\n// two\nthree"
+ if got := tab.Buffer.String(); got != want {
+ t.Fatalf("buffer:\n%q\nwant:\n%q", got, want)
+ }
+}
+
+// TestToggleLineComment_NoSelectionUsesCursorLine makes the menu item useful
+// even when the user has not highlighted text first.
+func TestToggleLineComment_NoSelectionUsesCursorLine(t *testing.T) {
+ tab := commentTestTab("main.go", "one\ntwo\nthree")
+ tab.Cursor = Position{Line: 1, Col: 1}
+ tab.Anchor = tab.Cursor
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ want := "one\n// two\nthree"
+ if got := tab.Buffer.String(); got != want {
+ t.Fatalf("buffer:\n%q\nwant:\n%q", got, want)
+ }
+}
+
+// TestToggleLineComment_BlankSelectionIsNoop avoids adding comment markers
+// to whitespace-only lines just because they were inside the selection.
+func TestToggleLineComment_BlankSelectionIsNoop(t *testing.T) {
+ tab := commentTestTab("main.go", " \n\t")
+ tab.SelectAll()
+
+ changed, ok := tab.ToggleLineComment()
+
+ if !ok {
+ t.Fatal("blank Go selection should still have a known comment syntax")
+ }
+ if changed {
+ t.Fatal("blank-only selection should not change the buffer")
+ }
+ if tab.Dirty || tab.CanUndo() {
+ t.Fatal("blank-only selection should not dirty the tab or push undo")
+ }
+}
+
+// TestToggleLineComment_UnsupportedFileTypeIsNoop protects formats like HTML
+// where a line-comment marker would be wrong.
+func TestToggleLineComment_UnsupportedFileTypeIsNoop(t *testing.T) {
+ tab := commentTestTab("index.html", "")
+
+ changed, ok := tab.ToggleLineComment()
+
+ if ok || changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want unsupported noop", changed, ok)
+ }
+ if got := tab.Buffer.String(); got != "" {
+ t.Fatalf("buffer changed for unsupported type: %q", got)
+ }
+}
+
+// TestToggleLineComment_UndoRestoresSelectionAndText confirms the action is
+// one structural undo step, including the cursor and active selection.
+func TestToggleLineComment_UndoRestoresSelectionAndText(t *testing.T) {
+ tab := commentTestTab("main.go", "one\ntwo")
+ tab.Anchor = Position{Line: 0, Col: 1}
+ tab.Cursor = Position{Line: 1, Col: 2}
+
+ changed, ok := tab.ToggleLineComment()
+ if !ok || !changed {
+ t.Fatalf("ToggleLineComment() = %v, %v; want changed and ok", changed, ok)
+ }
+ if !tab.Undo() {
+ t.Fatal("Undo should restore the pre-toggle snapshot")
+ }
+ if got := tab.Buffer.String(); got != "one\ntwo" {
+ t.Fatalf("undo buffer = %q, want original", got)
+ }
+ if tab.Anchor != (Position{Line: 0, Col: 1}) || tab.Cursor != (Position{Line: 1, Col: 2}) {
+ t.Fatalf("undo selection = anchor %+v cursor %+v", tab.Anchor, tab.Cursor)
+ }
+}
+
+// commentTestTab constructs a text tab with undo initialized, without touching
+// the filesystem.
+func commentTestTab(path, text string) *Tab {
+ t := &Tab{
+ Path: path,
+ Buffer: NewBuffer(text),
+ StyleStale: false,
+ }
+ t.initUndo()
+ return t
+}