Skip to content

Commit d1788be

Browse files
ymansurozerclaude
andauthored
feat: go to line by typing its number (#9)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 02e6bd4 commit d1788be

5 files changed

Lines changed: 125 additions & 18 deletions

File tree

src/ui/cursor.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -157,22 +157,56 @@ function ensureCursor(): Row | undefined {
157157
return r;
158158
}
159159

160-
// Land the cursor on a specific rendered line (display space) and center it — used by the
161-
// blockers jump list. Retries once across two frames so a just-triggered collapsed-region
162-
// expansion (which rerenders) has laid out its rows.
160+
// Land the cursor on a specific rendered line (display space) and center it. Context rows merge
161+
// to a single entry (additions primary, deletions twin in `alt`), so fall back to a line-only
162+
// match before giving up.
163+
function landAt(side: Side, line: number): boolean {
164+
const list = rows();
165+
const r = list.find((x) => matches(x, side, line)) ?? list.find((x) => x.line === line);
166+
if (!r) return false;
167+
cur = { side: r.side, line: r.line };
168+
paint(r);
169+
r.el.scrollIntoView({ block: "center" });
170+
return true;
171+
}
172+
173+
// Jump used by the blockers list. Retries once across two frames so a just-triggered
174+
// collapsed-region expansion (which rerenders) has laid out its rows.
163175
export function cursorJumpTo(side: Side, line: number) {
164-
const land = () => {
165-
const list = rows();
166-
// Context rows merge to a single entry (additions primary, deletions twin in `alt`), so
167-
// fall back to a line-only match before giving up.
168-
const r = list.find((x) => matches(x, side, line)) ?? list.find((x) => x.line === line);
169-
if (!r) return false;
170-
cur = { side: r.side, line: r.line };
171-
paint(r);
172-
r.el.scrollIntoView({ block: "center" });
173-
return true;
174-
};
175-
if (!land()) requestAnimationFrame(() => requestAnimationFrame(() => land()));
176+
if (!landAt(side, line))
177+
requestAnimationFrame(() => requestAnimationFrame(() => landAt(side, line)));
178+
}
179+
180+
// ── Go to line ───────────────────────────────────────────────────────────────
181+
// Typing digits in the diff accumulates a line number (shown as the goline pill); ↵ or a short
182+
// idle pause commits the jump, Esc cancels. Commit only moves the cursor — the existing ↵ /
183+
// ⇧Y / r bindings take over from the landed line, so the jump composes with every verb.
184+
185+
let golineTimer: ReturnType<typeof setTimeout> | undefined;
186+
187+
export function golineActive(): boolean {
188+
return !!S.golineBuffer;
189+
}
190+
191+
export function golineDigit(d: string) {
192+
if (!S.golineBuffer && d === "0") return; // a leading 0 can't start a real line number
193+
S.golineBuffer += d;
194+
clearTimeout(golineTimer);
195+
golineTimer = setTimeout(golineCommit, 800);
196+
}
197+
198+
export function golineCancel() {
199+
S.golineBuffer = "";
200+
clearTimeout(golineTimer);
201+
}
202+
203+
export function golineCommit() {
204+
const n = parseInt(S.golineBuffer, 10);
205+
golineCancel();
206+
if (!Number.isFinite(n)) return;
207+
// Prefer the additions/new side — the number a reviewer reads off the gutter. No retry:
208+
// goline never triggers an expansion, so a miss means the line isn't rendered.
209+
if (!landAt("additions", n)) toast(`Line ${n} isn't visible in this diff`);
176210
}
177211

178212
export function cursorMoveLine(dir: 1 | -1) {

src/ui/index.html

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2192,6 +2192,39 @@
21922192
display: block
21932193
}
21942194

2195+
/* Go-to-line pill: mode indicator for pending typed digits (neutral, unlike the green toast). */
2196+
.goline {
2197+
position: absolute;
2198+
left: 50%;
2199+
bottom: 46px;
2200+
transform: translateX(-50%);
2201+
border: 1px solid var(--line-strong);
2202+
background: var(--panel);
2203+
color: var(--ink);
2204+
border-radius: var(--radius-pill);
2205+
padding: 7px 12px;
2206+
font-size: var(--text-sm);
2207+
display: none;
2208+
align-items: center;
2209+
gap: 6px
2210+
}
2211+
2212+
.goline.show {
2213+
display: flex
2214+
}
2215+
2216+
.goline b {
2217+
color: var(--ink-bright)
2218+
}
2219+
2220+
.goline .goline-hint {
2221+
color: var(--muted);
2222+
display: flex;
2223+
align-items: center;
2224+
gap: 4px;
2225+
margin-left: 4px
2226+
}
2227+
21952228
@media(max-width:1100px) {
21962229
body {
21972230
overflow: auto
@@ -2436,6 +2469,9 @@
24362469
</div>
24372470
</div>
24382471
<div class="toast" id="toast" :class="{show: $store.g.toastMsg}" x-text="$store.g.toastMsg">saved</div>
2472+
<div class="goline" :class="{show: $store.g.golineBuffer}">Go to line&nbsp;<b
2473+
x-text="$store.g.golineBuffer"></b><span class="goline-hint"><kbd></kbd> Jump · <kbd>esc</kbd>
2474+
Cancel</span></div>
24392475
</main>
24402476
</div>
24412477
<script type="module" src="/ui.js"></script>

src/ui/keys.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
cursorComment,
88
cursorVerdict,
99
cursorResolve,
10+
golineActive,
11+
golineDigit,
12+
golineCommit,
13+
golineCancel,
1014
} from "./cursor";
1115

1216
// ── Central keyboard map ─────────────────────────────────────────────────────
@@ -20,10 +24,11 @@ type Hotkey = {
2024
desc: string;
2125
group: Group;
2226
test: (e: KeyboardEvent) => boolean;
23-
run: () => void;
27+
run: (e: KeyboardEvent) => void;
2428
when?: () => boolean; // scope guard (default: anywhere not typing)
2529
typing?: boolean; // also fires while typing in the composer
2630
hide?: boolean; // omit from the help overlay
31+
goline?: boolean; // part of the go-to-line gesture — doesn't cancel a pending digit buffer
2732
};
2833

2934
// Scopes
@@ -48,6 +53,10 @@ const cmdShift = (key: string) => (e: KeyboardEvent) =>
4853

4954
// Esc cascade: close the topmost transient surface.
5055
function escape() {
56+
if (golineActive()) {
57+
golineCancel();
58+
return;
59+
}
5160
if (S.confirmMsg) {
5261
S.confirmMsg = "";
5362
return;
@@ -175,6 +184,15 @@ const HOTKEYS: Hotkey[] = [
175184
run: () => cursorMoveHunk(-1),
176185
hide: true,
177186
},
187+
{
188+
combo: "1–9",
189+
desc: "Go to line (↵ jump, esc cancel)",
190+
group: "Navigate",
191+
test: (e) => /^[0-9]$/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey,
192+
when: inDiff,
193+
run: (e) => golineDigit(e.key),
194+
goline: true,
195+
},
178196
{
179197
combo: "o",
180198
desc: "Overview",
@@ -191,6 +209,17 @@ const HOTKEYS: Hotkey[] = [
191209
when: inOverview,
192210
run: () => S.startGuided?.(),
193211
},
212+
// Goline commit must outrank "comment on line" while digits are pending — same key, same scope.
213+
{
214+
combo: "↵",
215+
desc: "Jump to typed line",
216+
group: "Navigate",
217+
test: enter,
218+
when: () => inDiff() && golineActive(),
219+
run: () => golineCommit(),
220+
goline: true,
221+
hide: true,
222+
},
194223
// Comment
195224
{
196225
combo: "↵",
@@ -320,7 +349,8 @@ const HOTKEYS: Hotkey[] = [
320349
group: "App",
321350
test: (e) => e.key === "Escape",
322351
typing: true,
323-
run: escape,
352+
run: escape, // cancels pending goline digits first (see escape()), so no dispatcher pre-cancel
353+
goline: true,
324354
},
325355
];
326356

@@ -336,8 +366,11 @@ export function installKeys() {
336366
if (!h.test(e)) continue;
337367
if (typing && !h.typing) continue;
338368
if (h.when && !h.when()) continue;
369+
// Any other action abandons pending goline digits — otherwise the idle timer would
370+
// yank the cursor away ~800ms after e.g. a j/⇧Y that already moved on.
371+
if (!h.goline) golineCancel();
339372
e.preventDefault();
340-
h.run();
373+
h.run(e);
341374
return;
342375
}
343376
});

src/ui/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const S: Store = Alpine.reactive<Store>({
2424
composerOpen: false,
2525
popoverOpen: false,
2626
toastMsg: "",
27+
golineBuffer: "",
2728
composerTitle: "New line",
2829
composerBody: "",
2930
editingCommentId: null,

src/ui/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export interface Store {
164164
composerOpen: boolean;
165165
popoverOpen: boolean;
166166
toastMsg: string;
167+
// Pending "go to line" digits typed in the diff ("" = inactive). Drives the goline pill;
168+
// ↵ / idle timeout commits the jump, Esc cancels (see cursor.ts goline section).
169+
golineBuffer: string;
167170
composerTitle: string;
168171
composerBody: string;
169172
editingCommentId: string | null;

0 commit comments

Comments
 (0)