From cf8ada35b1c1676c90559f34f3f9841d0239c06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Wed, 4 Nov 2026 11:03:57 +0100 Subject: [PATCH 1/2] fix: / in an open issue searches the board, find moves to ctrl+f The web drops its in-issue find; the browser's ctrl+f covers it. --- docs/guide/02-board-and-panel.md | 4 +- docs/reference/browser-keys.md | 1 - docs/reference/terminal-keys.md | 5 +- internal/i18n/nl/ui-shell.json | 1 + internal/i18n/nl/web-board-issue.json | 7 --- internal/ui/harness_test.go | 2 + internal/ui/help.go | 3 +- internal/ui/keys.go | 4 +- internal/ui/keys_test.go | 4 +- internal/ui/model.go | 3 +- internal/ui/palette.go | 2 +- internal/ui/panel_find_test.go | 18 ++++++-- internal/ui/ref.go | 6 ++- internal/web/README.md | 2 +- internal/web/parity_test.go | 1 + internal/web/static/css/mdedit.css | 5 +- internal/web/static/js/views/issue.js | 51 --------------------- internal/web/static/js/views/issue_notes.js | 2 +- 18 files changed, 41 insertions(+), 80 deletions(-) diff --git a/docs/guide/02-board-and-panel.md b/docs/guide/02-board-and-panel.md index 3f1147aa..9a4338e5 100644 --- a/docs/guide/02-board-and-panel.md +++ b/docs/guide/02-board-and-panel.md @@ -120,7 +120,8 @@ red once breached. | `backspace` | back to the issue you came from | | `N` | your private notes on it, a file on this machine (`$EDITOR`) | | `ctrl+a` | ask `ui.llm` (`claude -p` by default): a summary, acceptance criteria, subtasks, points | - | `/` | find text in the issue; `n` / `N` step through the hits | + | `ctrl+f` | find text in the issue; `n` / `N` step through the hits | + | `/` | search the board | | `i` | the issue's images full size | | `o` | open it in the browser | | `y` / `Y` / `ctrl+y` | copy the key / the URL / a branch name | @@ -143,7 +144,6 @@ red once breached. | `[` `]` | the previous, next issue of the list you came from | | `N` | your private notes on it, edited in place | | `ctrl+a` | ask `ui.llm` (`claude -p` by default): a summary, acceptance criteria, subtasks, points | - | `/` | find text in the issue; `n` / `N` step through the hits | | `i` | the issue's images, larger | | `o` | open it in Jira | | `y` / `Y` / `ctrl+y` | copy the key / the link / a branch name | diff --git a/docs/reference/browser-keys.md b/docs/reference/browser-keys.md index 023ab42e..35043c1c 100644 --- a/docs/reference/browser-keys.md +++ b/docs/reference/browser-keys.md @@ -127,7 +127,6 @@ are its tabs. | `[` `]` | previous, next issue of the list you came from | | | `backspace` | back along the trail | | | `i` | images | | -| `/`, `n` `N` | find in the issue | | | `<` `>` | widen, narrow the panel | | | `*` `o` `y` `Y` | pin, Jira, copy key, link | | | `ctrl+\` | its agent's terminal; `t` takes over | | diff --git a/docs/reference/terminal-keys.md b/docs/reference/terminal-keys.md index eb4a83f0..99fd5d24 100644 --- a/docs/reference/terminal-keys.md +++ b/docs/reference/terminal-keys.md @@ -104,7 +104,8 @@ panel's page. Keys for the browser: [Browser keys](browser-keys.md). | `S` | start work, or attach to its agent | | `N` | private notes in `$EDITOR` | | `ctrl+a` | ask `ui.llm` about the issue | -| `/`, `n` `N` | find in the issue, next, previous hit | +| `ctrl+f`, `n` `N` | find in the issue, next, previous hit | +| `/` | search the board | | `i` | images full size | | `*` | pin | | `o`, `y` `Y`, `ctrl+y` | browser, copy key, URL, branch | @@ -271,7 +272,7 @@ A key bound to two actions on one screen is reported. The actions: | Screen | Actions | | --- | --- | | Board | `up down left right top bottom page_up page_down open toggle_panel browser refresh quit help search goto create copy_key copy_url copy_branch move_left move_right rank_up rank_down rank_top rank_bottom move_sprint project board next_view prev_view toggle_mode sort fold unfold_all next_band prev_band compact empty_lanes lane_layout arrange_lanes assignee_filter mine clear_filters filter_builder jql mark mark_all undo bulk quick_edit repeat refine pin palette roadmap plan charts releases timer timesheet inbox standup my_work review closed_sprint time_machine agents merge_requests home site settings panel_wider panel_narrower` | -| Panel | `status priority points summary labels assign description comment reply next_comment prev_comment delete_comment log_work start_work linked_issue back image issue_actions history development notes ask` | +| Panel | `status priority points summary labels assign description comment reply next_comment prev_comment delete_comment log_work start_work linked_issue back image find issue_actions history development notes ask` | | Agent terminal | `agent_back` | | Planning | `plan_start plan_goal plan_rename plan_new plan_complete` | | Charts | `chart_done chart_compare` | diff --git a/internal/i18n/nl/ui-shell.json b/internal/i18n/nl/ui-shell.json index 9a63b4d9..a90925da 100644 --- a/internal/i18n/nl/ui-shell.json +++ b/internal/i18n/nl/ui-shell.json @@ -424,6 +424,7 @@ "search": "zoeken", "search %s /%s": "zoeken %s /%s", "search (esc clears)": "zoeken (esc wist)", + "search the board": "het bord doorzoeken", "select a comment: R reply, ↵ edit yours, %s twice delete": "een reactie selecteren: R antwoord, ↵ je eigen bewerken, %s twee keer verwijderen", "select it; letting go copies": "selecteren; loslaten kopieert", "select; a field again: edit": "selecteren; een veld nogmaals: bewerken", diff --git a/internal/i18n/nl/web-board-issue.json b/internal/i18n/nl/web-board-issue.json index e7091913..c6f79138 100644 --- a/internal/i18n/nl/web-board-issue.json +++ b/internal/i18n/nl/web-board-issue.json @@ -21,7 +21,6 @@ "%d empty lanes hidden": "%d lege banen verborgen", "%d failed": "%d mislukt", "%d flagged": "%d gemarkeerd", - "%d found": "%d gevonden", "%d hidden": "%d verborgen", "%d issue": "%d issue", "%d issue · ctrl+enter lists it as a view": "%d issue · ctrl+enter toont hem als weergave", @@ -292,7 +291,6 @@ "Filters": "Filters", "Find a person…": "Zoek een persoon…", "Find an issue or type its key…": "Zoek een issue of typ de key…", - "Find in this issue…": "Zoek in deze issue…", "First %d of %d issues · ctrl+enter lists them as a view": "Eerste %d van %d issues · ctrl+enter toont ze als weergave", "Flag": "Vlag", "Flag as an impediment": "Markeren als blokkade", @@ -366,7 +364,6 @@ "New issue in this epic": "Nieuwe issue in deze epic", "New subtask": "Nieuwe subtaak", "Next": "Volgende", - "Next (n)": "Volgende (n)", "Next (→)": "Volgende (→)", "No agent works on %s now.": "Er werkt nu geen agent aan %s.", "No board in %s": "Geen bord in %s", @@ -432,7 +429,6 @@ "Post notes": "Notities plaatsen", "Post your private notes on %s as a comment?": "Je privénotities op %s als reactie plaatsen?", "Previous": "Vorige", - "Previous (N)": "Vorige (N)", "Previous (←)": "Vorige (←)", "Priority": "Prioriteit", "Priority of %d issues": "Prioriteit van %d issues", @@ -648,7 +644,6 @@ "filter builder": "filterbouwer", "filter by assignee": "filteren op toegewezene", "filter cards": "kaarten filteren", - "find in the issue (n / N next, previous)": "zoeken in de issue (n / N volgende, vorige)", "first card in lane": "eerste kaart in baan", "fold the swimlane, or the stacked lane's section": "swimlane of sectie van gestapelde baan inklappen", "go to a linked issue, child or web link": "naar een gekoppelde issue, onderliggende of weblink", @@ -697,7 +692,6 @@ "next lane": "volgende baan", "next lane (time machine: a day later)": "volgende baan (tijdmachine: een dag later)", "next lane layout: the board's columns or a ui.lane_layouts entry": "volgende baanindeling: de kolommen van het bord of een ui.lane_layouts-item", - "next match": "volgende treffer", "next row": "volgende rij", "next swimlane, or the list's group": "volgende swimlane, of de groep van de lijst", "next view": "volgende weergave", @@ -730,7 +724,6 @@ "previous issue in the list": "vorige issue in de lijst", "previous lane": "vorige baan", "previous lane (time machine: a day earlier)": "vorige baan (tijdmachine: een dag eerder)", - "previous match": "vorige treffer", "previous row": "vorige rij", "previous swimlane, or the list's group": "vorige swimlane, of de groep van de lijst", "previous view": "vorige weergave", diff --git a/internal/ui/harness_test.go b/internal/ui/harness_test.go index 8097ddb9..2ac8b6e6 100644 --- a/internal/ui/harness_test.go +++ b/internal/ui/harness_test.go @@ -96,6 +96,8 @@ func keyMsg(t *testing.T, name string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl} case "ctrl+y": return tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl} + case "ctrl+f": + return tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl} case "alt+e": return tea.KeyPressMsg{Code: 'e', Mod: tea.ModAlt} case "alt+m": diff --git a/internal/ui/help.go b/internal/ui/help.go index af568aee..2cfc64c8 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -116,7 +116,8 @@ func (m *Model) helpSections() []struct { {join(k.CopyKey, k.CopyURL), i18n.T("copy key / URL")}, row(k.CopyBranch, i18n.T("copy branch name")), row(k.JiraLinks, i18n.T("go to linked issue, child or web link")), - row(k.Search, i18n.T("find in the issue (n / N next / previous)")), + row(k.Find, i18n.T("find in the issue (n / N next / previous)")), + row(k.Search, i18n.T("search the board")), row(k.Image, i18n.T("view images full size")), row(k.Back, i18n.T("previous issue")), row(k.Refresh, i18n.T("refresh")), diff --git a/internal/ui/keys.go b/internal/ui/keys.go index f50c8cd4..0fb801c9 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -20,7 +20,7 @@ func (k *keyMap) keyNames() map[string]*key.Binding { "status": &k.JiraStatus, "priority": &k.JiraPriority, "points": &k.JiraPoints, "summary": &k.JiraSummary, "labels": &k.JiraLabels, "assign": &k.JiraAssignee, "comment": &k.JiraComment, "reply": &k.JiraReply, "start_work": &k.JiraStart, "linked_issue": &k.JiraLinks, "back": &k.Back, "image": &k.Image, - "quit": &k.Quit, "help": &k.Help, "search": &k.Search, "goto": &k.Goto, "create": &k.Create, + "quit": &k.Quit, "help": &k.Help, "search": &k.Search, "find": &k.Find, "goto": &k.Goto, "create": &k.Create, "copy_key": &k.CopyKey, "copy_url": &k.CopyURL, "copy_branch": &k.CopyBranch, "move_left": &k.MoveCardLeft, "move_right": &k.MoveCardRight, "project": &k.Project, "board": &k.Board, "next_view": &k.NextView, "prev_view": &k.PrevView, @@ -77,7 +77,7 @@ var keyScopes = []struct { "next_view", "prev_view", "toggle_mode", "sort", "move_sprint", "assignee_filter", "mine", "clear_filters", "roadmap", "palette", "mark", "mark_all", "undo", "bulk", "plan", "charts", "timer", "timesheet", "inbox", "site", "standup", "jql", "pin", "fold", "unfold_all", "next_band", "prev_band", "settings", "filter_builder", "panel_wider", "panel_narrower", "quick_edit", "my_work", "compact", "empty_lanes", "releases", "repeat", "refine", "rank_up", "rank_down", "rank_top", "rank_bottom", "closed_sprint", "agents", "merge_requests", "home", }}, {"panel", []string{ - "status", "priority", "points", "summary", "labels", "assign", "comment", "reply", "start_work", "search", + "status", "priority", "points", "summary", "labels", "assign", "comment", "reply", "start_work", "search", "find", "linked_issue", "back", "image", "browser", "copy_key", "copy_url", "copy_branch", "help", "refresh", "toggle_panel", "palette", "log_work", "timer", "timesheet", "description", "inbox", "issue_actions", "standup", "history", "development", "pin", "next_view", "prev_view", "settings", "panel_wider", "panel_narrower", "undo", "next_comment", "prev_comment", "delete_comment", "agents", }}, diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go index ac4e7ef3..cbd290c6 100644 --- a/internal/ui/keys_test.go +++ b/internal/ui/keys_test.go @@ -29,11 +29,11 @@ func TestKeysConfigYAML(t *testing.T) { func TestRebindKeys(t *testing.T) { k := defaultKeys() - warn := k.applyKeys(map[string]config.KeyList{"search": {"ctrl+f"}, "bogus": {"x"}, "help": {}, "review": {"ctrl+r"}}) + warn := k.applyKeys(map[string]config.KeyList{"search": {"ctrl+k"}, "bogus": {"x"}, "help": {}, "review": {"ctrl+r"}}) if len(warn) != 2 || !strings.Contains(warn[0], "bogus") || !strings.Contains(warn[1], "help") { t.Errorf("warnings = %v", warn) } - if keysLabel(k.Search) != "ctrl+f" || k.Search.Help().Desc != "search" { + if keysLabel(k.Search) != "ctrl+k" || k.Search.Help().Desc != "search" { t.Errorf("search = %q %q", keysLabel(k.Search), k.Search.Help().Desc) } if keysLabel(k.Help) != "?" { diff --git a/internal/ui/model.go b/internal/ui/model.go index 50449008..50ca9347 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -65,7 +65,7 @@ type keyMap struct { History, DevInfo, JQL, Pin key.Binding Fold, UnfoldAll, Settings key.Binding NextBand, PrevBand key.Binding - FilterBuilder, PanelWider key.Binding + FilterBuilder, PanelWider, Find key.Binding PanelNarrower, QuickEdit, MyWork key.Binding Compact, Releases, Repeat key.Binding EmptyLanes, MergeRequests key.Binding @@ -132,6 +132,7 @@ func defaultKeys() keyMap { Quit: bind(i18n.T("quit"), "q"), Help: bind(i18n.T("help"), "?"), Search: bind(i18n.T("search"), "/"), + Find: bind(i18n.T("find in the issue"), "ctrl+f"), Goto: bind(i18n.T("go to issue by key"), "#"), Create: bind(i18n.T("new issue"), "n"), CopyKey: bind(i18n.T("copy key"), "y"), diff --git a/internal/ui/palette.go b/internal/ui/palette.go index fc02b483..0276538c 100644 --- a/internal/ui/palette.go +++ b/internal/ui/palette.go @@ -57,7 +57,7 @@ var paletteDesc = map[string]map[string]string{ "roadmap": {"move_left": i18n.N("move the bar a column earlier"), "move_right": i18n.N("move the bar a column later"), "end_earlier": i18n.N("move its end earlier"), "end_later": i18n.N("move its end later"), "quit": i18n.N("close the roadmap")}, "planning": {"quit": i18n.N("close planning")}, - "panel": {"search": i18n.N("find in the issue")}, + "panel": {"search": i18n.N("search the board")}, "charts": {"quit": i18n.N("close the charts")}, "standup": {"quit": i18n.N("close the standup"), "open": i18n.N("open the issue"), "prev_view": i18n.N("a workday further back"), "next_view": i18n.N("a workday later")}, "inbox": {"quit": i18n.N("close the inbox"), "open": i18n.N("open the thread's issue")}, diff --git a/internal/ui/panel_find_test.go b/internal/ui/panel_find_test.go index 96d5592e..3eb4c891 100644 --- a/internal/ui/panel_find_test.go +++ b/internal/ui/panel_find_test.go @@ -5,15 +5,15 @@ import ( "testing" ) -// TestPanelFind: / finds text in the panel, n steps on and wraps round, a +// TestPanelFind: ctrl+f finds text in the panel, n steps on and wraps round, a // miss says so. func TestPanelFind(t *testing.T) { m := loadedJiraModel(t) m.jiraIssue.Description = strings.Repeat("filler\n\n", 40) + "the widget broke\n\n" + strings.Repeat("more\n\n", 40) m.renderRef() - out, _ := m.handleRefKey(keyMsg(t, "/")) + out, _ := m.handleRefKey(keyMsg(t, "ctrl+f")) if m = out.(Model); !m.jiraFieldActive || m.jiraFieldName != "find" { - t.Fatal("/ should ask what to find") + t.Fatal("ctrl+f should ask what to find") } m.jiraFieldInput.SetValue("WIDGET") out, _ = m.applyJiraField() @@ -38,6 +38,18 @@ func TestPanelFind(t *testing.T) { } } +// TestPanelSlashSearchesBoard: / in the panel is the board's search, not +// the find in the issue. +func TestPanelSlashSearchesBoard(t *testing.T) { + m := loadedJiraModel(t) + m.focus = focusRef + out, _ := m.handleKey(keyMsg(t, "/")) + m = out.(Model) + if m.jiraFieldActive || !m.jiraTab.searching || m.focus != focusJira { + t.Fatalf("field %v, searching %v, focus %v", m.jiraFieldActive, m.jiraTab.searching, m.focus) + } +} + // TestPanelFindWrapped: a phrase the panel's wrap splits over two rows is // still found, on the row it starts. func TestPanelFindWrapped(t *testing.T) { diff --git a/internal/ui/ref.go b/internal/ui/ref.go index cd903370..b195229d 100644 --- a/internal/ui/ref.go +++ b/internal/ui/ref.go @@ -169,9 +169,13 @@ func (m Model) handleRefKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Palette): m.openPalette() return m, nil - case key.Matches(msg, m.keys.Search): + case key.Matches(msg, m.keys.Find): m.openPanelFind() return m, nil + case key.Matches(msg, m.keys.Search): + m.focus = focusJira + m.startJiraSearch() + return m, nil case m.panelFind != "" && (msg.String() == "n" || msg.String() == "N"): if msg.String() == "n" { m.findInPanel(1) diff --git a/internal/web/README.md b/internal/web/README.md index f309da92..20de4499 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -54,7 +54,7 @@ Scopes stack: global < view < panel (beside a board the panel's keys fire only w | Picker | type to filter, `↑↓`/`ctrl+n` `ctrl+p` move, `enter` picks, `esc`; one that takes several (labels, list columns, assignee filter): `space` (nothing typed), `tab` (clears what was typed; typed text matching nothing becomes a new label) or a click ticks, `enter` or Apply takes them | | Go | `g h` home, `g p` planning, `g r` reports, `g m` roadmap, `g w` my work, `g i` inbox, `g s` standup, `g a` agents, `g ,` settings, `g t` next theme, `g l` rules, `@` switch or add a Jira site (also with one site; the last row asks what `laneway setup` does, `POST /api/sites`, and the server starts again on it), `ctrl+a` ask the LLM about the issue (Edit as comment puts the answer in its comment composer, as the TUI; nothing posts until you do), your `ui.actions` keys | | Board | `hjkl`/arrows move, `enter` open, `t` lanes/list, `s` status, `e` summary, `a` assign, `p` priority, `P` points, `H`/`L` move column (several statuses: pick one; a move the workflow wants fields for opens its form; `u` undoes; dragging splits such a lane into one drop zone per status, unreachable ones dimmed), `J`/`K` rank, `M` to a sprint or the backlog (marked cards, else the selected; `.` repeats it, `u` undoes), `E` quick edit (the card menu at the card; a right-click opens it at the pointer: current values, submenus for status, assignee, priority and sprint (the board's open sprints and the backlog), points, labels, pin, open in a new tab; `↑↓` `enter`/`→` `←`/`esc`; a long press on a phone keeps the sheet), `x` mark (`ctrl+click` too, the opened card with it; `shift+click` marks the run within the lane or list), `X` bulk, `o` Jira, `y` copy key, `r` refresh, `O` swimlanes (lanes) / sort (list; by assignee, priority, epic or status it heads each group with its count and points; a drop into another swimlane gives the card its assignee or epic, `u` undoes), `z`/`Z` fold/unfold swimlane, `c` compact (one-line) cards, `alt+e` hide/show empty lanes, `alt+l` next lane layout (`ui.lane_layouts`, arranged server-side by internal/lanes; stacked columns get a header over their first card), `C` list columns (drag a header onto another to move its column, its right edge to resize it, a double-click on the edge resets it; pref `board.colw`), `V` releases (the report), `F` filter builder (as the TUI's: `↑↓` pick, `→`/`tab` and `←`/`shift+tab` column, `enter` deeper then add, `ctrl+x` drop last term, `esc`), `*` pin, `.` repeat, `alt+j`/`alt+k` rank bottom/top, `ctrl+a` mark all, `ctrl+y` copy branch, `Y` copy link, `alt+t` time machine (`←` `→` `esc`), `alt+o` closed sprints, `B` board, `alt+p` project, `v` view, `[`/`]` previous/next view (sprints, backlog, `ui.views`, starred filters), `f` filter, `m` mine, `A` assignee (the project's assignable people; `space` (nothing typed), `tab` (clears what was typed) or a click ticks several, `enter` or Apply takes them, `enter` without a tick takes the row; `m` stays one toggle), `0` clear, `1`-`9` quick filters; the bar lists Mine, Assignee, then the quick filters (`ui.quick_filters` first); `is:notes` in `f` finds cards with private notes | -| Issue panel | A link to an issue on this site (descriptions, comments, notes, inbox, merge requests) draws as a pill, status, key, summary and assignee (`lib/issuepill.js`, `GET /api/issues/{key}/card`), and opens it here (a link with its own text keeps it); Details lists the edit screen's other fields (components, custom fields) after its own, a click edits one: the pinned ones (the pin on hover, the site's every project, shared with the TUI; its own fields pinned till unpinned; `PUT /api/fields/pinned/{id}`, `jira.FieldPins`), the rest under More (remembered open), `ui.empty_fields: hide` folds their empty ones; a filled rich-text field is a section under the description, edited like it (`GET`/`PUT /api/issues/{key}/doc/{field}`); a closed issue's fields come read-only from the screen last seen for its project and type (`jira/layout.go`); that screen also comes with the issue (`Issue.Screen`, its fields asked along), so the fields draw at once and editmeta changes nothing in place; `j`/`k` comment or scroll, `c` comment (`ctrl+o` in it: who sees it, everyone, an internal note or a project role), `R` reply, `e` summary (own focused comment), `E` description, `a` `p` `P` `l` assignee, priority, points, labels (Jira's complete as you type), `s` status, `L` link issue, `A` actions (subtask, link, each agent of the issue: type into, prompt, stop, a new agent in its directory; on a done issue remove its worktree once merged and clean; draft pull request when `jira.repos` has the project; clone (opens the create form filled in; creating links it), type, move, original estimate, dependency tree, delete, watchers, vote, flag, upload), `G` go to a linked issue (a Confluence page of this site, there or in Links, opens in place as markdown, its macros named, its images through `GET /api/confluence/images/{id}`, `GET /confluence/pages/{id}`; `ctrl+click` opens Confluence), `Backspace` back along the visible trail of followed issues (links, children, parent, `G`, palette; click a row to jump back; opening from the board resets it), `i` image viewer (arrows, `d` download), `/` find in the issue (`n`/`N`; `/` is search outside the panel), `d` delete own comment (`u` posts it back), a comment's Copy link copies its Jira link (`?focusedCommentId=`), `[`/`]` previous/next issue of the list beside, `1`-`4` tabs (`3` again steps History through Changes, Work log and All: comments, changes and work together; its box filters; a changed description shows the lines it changed), `4` Terminal (only while a herdr agent works on the issue: its live terminal; `4` again the next agent, `enter` or `ctrl+\` type, `t` take over input, `j`/`k` scroll back), `N` private notes, `D` development (in Details: pull requests, deploys, builds, branches, commits; `j`/`k` rows, `enter` open (a GitLab merge request unfolds: state, pipeline by stage, approvals, description; `GET /api/gitlab/mr?url=`; `d` its diff), `o` open in the browser, `r` reload, `y` copy branch/hash, `esc` back), `y`/`Y` copy key/link, `ctrl+y` copy branch, `*` pin, `<`/`>` widen/narrow (5%, stopping at `ui.panel_width`; a drag snaps to it within 3%, 20–80%), `o` Jira, `r` refresh, `ctrl+enter` save; a comment, comment edit or description being written is kept as a draft in the state file (shared with the TUI) and comes back after a reload; esc or Cancel with changes asks first; expands start folded | +| Issue panel | A link to an issue on this site (descriptions, comments, notes, inbox, merge requests) draws as a pill, status, key, summary and assignee (`lib/issuepill.js`, `GET /api/issues/{key}/card`), and opens it here (a link with its own text keeps it); Details lists the edit screen's other fields (components, custom fields) after its own, a click edits one: the pinned ones (the pin on hover, the site's every project, shared with the TUI; its own fields pinned till unpinned; `PUT /api/fields/pinned/{id}`, `jira.FieldPins`), the rest under More (remembered open), `ui.empty_fields: hide` folds their empty ones; a filled rich-text field is a section under the description, edited like it (`GET`/`PUT /api/issues/{key}/doc/{field}`); a closed issue's fields come read-only from the screen last seen for its project and type (`jira/layout.go`); that screen also comes with the issue (`Issue.Screen`, its fields asked along), so the fields draw at once and editmeta changes nothing in place; `j`/`k` comment or scroll, `c` comment (`ctrl+o` in it: who sees it, everyone, an internal note or a project role), `R` reply, `e` summary (own focused comment), `E` description, `a` `p` `P` `l` assignee, priority, points, labels (Jira's complete as you type), `s` status, `L` link issue, `A` actions (subtask, link, each agent of the issue: type into, prompt, stop, a new agent in its directory; on a done issue remove its worktree once merged and clean; draft pull request when `jira.repos` has the project; clone (opens the create form filled in; creating links it), type, move, original estimate, dependency tree, delete, watchers, vote, flag, upload), `G` go to a linked issue (a Confluence page of this site, there or in Links, opens in place as markdown, its macros named, its images through `GET /api/confluence/images/{id}`, `GET /confluence/pages/{id}`; `ctrl+click` opens Confluence), `Backspace` back along the visible trail of followed issues (links, children, parent, `G`, palette; click a row to jump back; opening from the board resets it), `i` image viewer (arrows, `d` download), `d` delete own comment (`u` posts it back), a comment's Copy link copies its Jira link (`?focusedCommentId=`), `[`/`]` previous/next issue of the list beside, `1`-`4` tabs (`3` again steps History through Changes, Work log and All: comments, changes and work together; its box filters; a changed description shows the lines it changed), `4` Terminal (only while a herdr agent works on the issue: its live terminal; `4` again the next agent, `enter` or `ctrl+\` type, `t` take over input, `j`/`k` scroll back), `N` private notes, `D` development (in Details: pull requests, deploys, builds, branches, commits; `j`/`k` rows, `enter` open (a GitLab merge request unfolds: state, pipeline by stage, approvals, description; `GET /api/gitlab/mr?url=`; `d` its diff), `o` open in the browser, `r` reload, `y` copy branch/hash, `esc` back), `y`/`Y` copy key/link, `ctrl+y` copy branch, `*` pin, `<`/`>` widen/narrow (5%, stopping at `ui.panel_width`; a drag snaps to it within 3%, 20–80%), `o` Jira, `r` refresh, `ctrl+enter` save; a comment, comment edit or description being written is kept as a draft in the state file (shared with the TUI) and comes back after a reload; esc or Cancel with changes asks first; expands start folded | | Home | `ui.home`'s widgets, in its order, as the TUI's `~` (`internal/home`; with none set, `g h` shows every one and the board stays the start): my work (open), inbox (unread), the remembered board's active sprint (done against the time gone, behind when it trails by a tenth), the timer, waiting on my review, a count per starred Jira filter and starred search (`GET /home/sprint/{board}`, `GET /home/filters`); `jk`, `enter` opens the row (a search as a board view), a head's Open its view, `esc` the board, `r` refresh | | Planning | `jk` move, `Home`/`End`, `PgUp`/`PgDn`, `J`/`K` rank, `m` move to sprint, `[`/`]` previous/next sprint, `x`/`space` select (`ctrl+click` too, the opened issue with it; `shift+click` selects the run), `z` fold, `enter` open, `P` points, `a` assignee, `e` quick edit, `X` bulk edit the marked, `o` Jira, `y` copy the section as a markdown table, `u` undoes a move, `N` `Z` `C` `E` new, start, complete (with `ui.delight` it says how the sprint ranks), edit sprint, `B` board, `alt+p` project, `f` filter (the board's query language), `F` filter builder, `A` filter by assignee (several, as on the board); the board's chips: Mine (no key: `m` moves), Assignee, `1`-`9` its quick filters (Jira narrows every section: `GET /plan/{board}?jql=`, which lists them as `QuickFilters`), `0` clear, `|` split (a second pane keeps one sprint or the backlog in view, picked in its bar and remembered per board, and leaves it out of the list; `>` moves there, `tab` or `h`/`l` switches pane, drag between panes; wide screens only), `O` sort, `R` reload; the list is the board's (`lib/cardlist.js`): a header click sorts within each section, again reverses, a third time back to rank (sorted, `J`/`K` don't rank and a drop goes to the section's end), each pane of the split sorts on its own (prefs `planning.sort.`, `planning.sort.split.`), Columns picks them, a dragged header moves one and its dragged edge resizes it (prefs `planning.cols`, `planning.colw`); a head counts the unestimated; each sprint head shows the points per assignee against `ui.capacity`, yours less your `ui.calendar` meetings in the sprint (the hours in its tooltip; `GET /plan/{board}` `Sprints[].Mine`) | | Reports | `h`/`l`/`[`/`]`/arrows previous/next report, `1`-`7` pick, `s` sprint, `B` board, `alt+p` project, `W` weeks (cycle time), `d` the column done counts from (`ui.report_done`), `c` a second line for the visit (`?compare=`; burndown, burnup, flow, cycle time and retro; velocity and releases keep Jira's done), `r` release (releases; every released version listed, `enter` its issues as a board view), `y` copy the numbers as a markdown table, `R` reload, `jk` items | diff --git a/internal/web/parity_test.go b/internal/web/parity_test.go index 834d9ec3..12b42068 100644 --- a/internal/web/parity_test.go +++ b/internal/web/parity_test.go @@ -24,6 +24,7 @@ import ( // tuiOnly: TUI action -> why the web binds no key to it. var tuiOnly = map[string]string{ "quit": "the browser closes its tab", + "find": "the browser's own ctrl+f finds in the page", "plan_goal": "one form edits the sprint's name, goal and end: plan_rename (E)", "arrange_lanes": "the browser arranges lanes in the settings' lane editor, by dragging", } diff --git a/internal/web/static/css/mdedit.css b/internal/web/static/css/mdedit.css index c1e25131..5f418ef5 100644 --- a/internal/web/static/css/mdedit.css +++ b/internal/web/static/css/mdedit.css @@ -107,10 +107,7 @@ #view:has(.ed.full) { contain: none; } /* contain would hold it under the header; body:has() would cost every keystroke a walk of the page */ .ed-files { display: flex; flex-wrap: wrap; gap: .286rem; margin-top: .429rem; } -/* find in issue, image viewer */ -.iss-find { display: flex; align-items: center; gap: .429rem; padding: .286rem calc(var(--pad) * 2); background: var(--bg-3); border-bottom: 1px solid var(--border); } -.iss-find input { flex: 1; min-width: 0; } -mark.find { background: var(--warn); color: var(--bg); border-radius: 2px; } mark.find.cur { outline: 2px solid var(--accent); } +/* image viewer */ .lb { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; } .lb .lightbox { max-width: 100%; max-height: 75vh; } .lb .player.lightbox { --player-max-h: 75vh; } diff --git a/internal/web/static/js/views/issue.js b/internal/web/static/js/views/issue.js index f9d8ef2d..4d4526d7 100644 --- a/internal/web/static/js/views/issue.js +++ b/internal/web/static/js/views/issue.js @@ -530,53 +530,6 @@ export function mountIssue(el, key, { app, full, card }) { if (it.k) open(it.k); else if (it.w.Page) readPage(it.w); else window.open(safeHref(it.url), '_blank', 'noopener'); } - // Find in the issue: marks matches in all tabs; n / N (or enter) step, esc clears. - const find = { on: false, marks: [], at: -1 }; - const findBar = h('div.iss-find', { hidden: true }); - const findIn = h('input.input', { type: 'text', placeholder: T('Find in this issue…'), spellcheck: false, oninput: () => findRun(findIn.value) }); - const findCount = h('span.dim'); - findBar.append(findIn, findCount, h('button.btn.ghost.sm', { onclick: () => findStep(-1), title: T('Previous (N)'), 'aria-label': T('Previous') }, icon('arrow-up'), keyHint('N')), h('button.btn.ghost.sm', { onclick: () => findStep(1), title: T('Next (n)'), 'aria-label': T('Next') }, icon('arrow-down'), keyHint('n')), h('button.btn.ghost.sm', { onclick: () => findClose(), title: T('Close (esc)'), 'aria-label': T('Close') }, icon('x'), keyHint('Escape'))); - findIn.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); findStep(e.shiftKey ? -1 : 1); findIn.blur(); } - else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); findClose(); } - }); - head.after(findBar); - function findClear() { - for (const m of find.marks) { const t = document.createTextNode(m.textContent); m.replaceWith(t); t.parentNode && t.parentNode.normalize(); } - find.marks = []; find.at = -1; - } - function findOpen() { find.on = true; findBar.hidden = false; findIn.focus(); findIn.select(); } - function findClose() { findClear(); find.on = false; findBar.hidden = true; findIn.value = ''; findCount.textContent = ''; scroll.focus({ preventScroll: true }); } - function findRun(q) { - findClear(); - q = q.trim().toLowerCase(); - if (q.length < 2) { findCount.textContent = ''; return; } - const hits = [], w = document.createTreeWalker(scroll, NodeFilter.SHOW_TEXT); - for (let n = w.nextNode(); n && hits.length < 500; n = w.nextNode()) { - if (n.parentElement.closest('textarea,script,style,.ed')) continue; - const t = n.nodeValue.toLowerCase(); - for (let i = t.indexOf(q); i >= 0; i = t.indexOf(q, i + q.length)) hits.push([n, i]); - } - for (let k = hits.length - 1; k >= 0; k--) { - const [n, i] = hits[k]; - const r = document.createRange(); r.setStart(n, i); r.setEnd(n, i + q.length); - const mk = h('mark.find'); r.surroundContents(mk); find.marks.unshift(mk); - } - findCount.textContent = find.marks.length ? T('%d found', find.marks.length) : T('none'); - if (find.marks.length) findStep(1, true); - } - function findStep(d, first) { - if (!find.marks.length) return; - find.marks[find.at]?.classList.remove('cur'); - find.at = first ? 0 : (find.at + d + find.marks.length) % find.marks.length; - const m = find.marks[find.at]; m.classList.add('cur'); - const pane = Object.entries(panes).find(([, p]) => p.contains(m)); - if (pane && pane[0] !== st.tab) setTab(pane[0]); - for (let d = m.closest('details'); d; d = d.parentElement.closest('details')) d.open = true; // a folded expand opens to show it - m.scrollIntoView({ block: 'center' }); - findCount.textContent = find.at + 1 + '/' + find.marks.length; - } - // ---- comments // Who a comment is for, as the composer and a comment's lock name it. const visWho = v => (v.Internal ? T('internal note') : v.Role || v.Group || ''); @@ -944,7 +897,6 @@ export function mountIssue(el, key, { app, full, card }) { // ---- keys const G = T('Issue'); scope.bind('Escape', e => { - if (find.on && !(e.target.closest && e.target.closest('.ed'))) return findClose(); const ed = e.target.closest && e.target.closest('.ed'); if (ed) { if (ed._escape && ed._escape()) return; if (ed._cancel) return ed._cancel(); } if (document.activeElement && document.activeElement.matches && document.activeElement.matches('input,textarea,select')) return document.activeElement.blur(); @@ -973,9 +925,6 @@ export function mountIssue(el, key, { app, full, card }) { scope.bind('Backspace', back, T('back along the trail of followed issues'), { group: G }); scope.bind('G', linked, T('go to a linked issue, child or web link'), { group: G }); scope.bind('i', () => gallery(0), T('view images'), { group: G }); - scope.bind('/', () => findOpen(), T('find in the issue (n / N next, previous)'), { group: G }); - scope.bind('n', () => findStep(1), T('next match'), { group: G, hidden: true, when: () => find.on && (full || app.panel.focused()) }); - scope.bind('N', () => findStep(-1), T('previous match'), { group: G, hidden: true, when: () => find.on && (full || app.panel.focused()) }); scope.bind('1', () => setTab('details'), T('details tab'), { group: G, bar: T('tabs') }); scope.bind('2', () => setTab('comments'), T('comments tab'), { group: G, bar: T('tabs') }); scope.bind('3', () => (st.tab === 'history' ? stepHist() : setTab('history')), T('history tab (again: work log, all)'), { group: G, bar: T('tabs') }); diff --git a/internal/web/static/js/views/issue_notes.js b/internal/web/static/js/views/issue_notes.js index 7f2eb6c6..c938511d 100644 --- a/internal/web/static/js/views/issue_notes.js +++ b/internal/web/static/js/views/issue_notes.js @@ -15,7 +15,7 @@ export function mountNotes(parent, key, { app, el, full }) { let text = '', dead = false, editing = false, open = false; const scope = app.keys.scope('issue-notes'); const inPanel = () => full || app.panel.focused(); - scope.bind('N', () => edit(), T('edit your private notes'), { group: T('Issue'), when: () => inPanel() && !el.querySelector('.iss-find:not([hidden])') }); + scope.bind('N', () => edit(), T('edit your private notes'), { group: T('Issue'), when: () => inPanel() }); async function save(v) { try { await api.put('/issues/' + key + '/notes', { Text: v }); } catch (e) { return ui.errToast(e); } From 0094130f32eaa206577da101b77ce9f44b542b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Wed, 4 Nov 2026 11:24:13 +0100 Subject: [PATCH 2/2] test: find the timer's stop dialog by its new title --- e2e/web/tests/details.spec.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/web/tests/details.spec.mjs b/e2e/web/tests/details.spec.mjs index d414ecbb..67f1aae7 100644 --- a/e2e/web/tests/details.spec.mjs +++ b/e2e/web/tests/details.spec.mjs @@ -69,7 +69,7 @@ test('T starts a timer and T again stops it into a work log', async ({ page }) = await reload(page); await expect(chip).toContainText('DEMO-4'); // the state file keeps it await page.keyboard.press('T'); - const dialog = page.getByRole('dialog', { name: 'Log work on DEMO-4' }); + const dialog = page.getByRole('dialog', { name: 'Stop timer on DEMO-4' }); await expect(dialog.getByRole('textbox', { name: 'Time' })).toHaveValue(/m$/); await dialog.getByRole('textbox', { name: 'Comment' }).fill('Timed'); await dialog.getByRole('button', { name: 'Log work' }).click();