diff --git a/README.md b/README.md index b1e5336..3299b3f 100644 --- a/README.md +++ b/README.md @@ -210,4 +210,4 @@ This permanently disables telemetry. Your preference is stored locally at `~/.co ## Credits -Built with [OpenTUI](https://github.com/anthropics/opentui) +Built with [OpenTUI](https://github.com/anomalyco/opentui) diff --git a/src/app.ts b/src/app.ts index 464f53a..96887cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -624,6 +624,10 @@ export class HackerNewsApp { this.rootCommentIndex = 0; updateStorySelection(this.storyListState, this.posts, previousIndex, index); + // Scroll immediately from the last laid-out frame. Detail fetching is + // independent — waiting on getPostById left the list unmoved when the + // network was slow or hung (selectedIndex updated, scrollTop stayed 0). + scrollToStory(this.storyListState, index); const post = this.posts[index]; if (!post) return; @@ -673,7 +677,6 @@ export class HackerNewsApp { } if (this.renderer.isDestroyed) return; - scrollToStory(this.storyListState, index); this.saveToCache(); } diff --git a/src/components/StoryList.ts b/src/components/StoryList.ts index 12bfde9..ea569b2 100644 --- a/src/components/StoryList.ts +++ b/src/components/StoryList.ts @@ -174,6 +174,10 @@ export function scrollToStory( const viewportHeight = state.scroll.viewport.height; const currentScroll = state.scroll.scrollTop; + // Layout has not run yet — do not treat a zero-size item as visible, + // and never reset an existing scroll position from stale 0,0 metrics. + if (viewportHeight <= 0 || item.height <= 0) return; + // Only scroll if the item is outside the visible viewport if (itemTop < currentScroll) { // Item is above viewport - scroll up to show it at top diff --git a/src/index.ts b/src/index.ts index 6c3f47d..57534ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import { createCliRenderer } from "@opentui/core"; -import { exec } from "child_process"; +import { execFile } from "node:child_process"; import { HackerNewsApp } from "./app"; import { checkForUpdates, currentVersion } from "./version"; import { setTelemetryEnabled } from "./config"; @@ -10,6 +10,16 @@ const COLORS = { bg: "#1a1a1a", }; +function openUrl(url: string): void { + if (process.platform === "darwin") { + execFile("open", [url]); + } else if (process.platform === "win32") { + execFile("cmd", ["/c", "start", "", url]); + } else { + execFile("xdg-open", [url]); + } +} + function parseArgs(): { storyId?: number } { const args = process.argv.slice(2); let storyId: number | undefined; @@ -64,9 +74,7 @@ async function main() { }); const app = new HackerNewsApp(renderer, { - onOpenUrl: (url) => { - exec(`open "${url}"`); - }, + onOpenUrl: openUrl, onExit: async () => { await telemetry.flushSync(); renderer.destroy(); diff --git a/src/test/app.test.ts b/src/test/app.test.ts index ba211ca..5c27f91 100644 --- a/src/test/app.test.ts +++ b/src/test/app.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { createAppTestContext, cleanupTestContext, type TestContext } from "./test-utils"; import { createMockPosts, createMockPostWithComments } from "./fixtures"; +import { scrollToStory } from "../components/StoryList"; describe("HackerNewsApp", () => { let ctx: TestContext; @@ -115,7 +116,26 @@ describe("HackerNewsApp", () => { }); describe("Story List Scroll", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + // List scroll must not depend on HN item fetches (CI cannot reach / hangs on hnpwa). + const mockFetch = async () => new Response(null, { status: 404 }); + mockFetch.preconnect = originalFetch.preconnect; + globalThis.fetch = mockFetch as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + it("should scroll down to show off-screen selected story", async () => { + // Hang the item fetch: scroll-into-view must not wait on getPostById. + const hangingFetch = () => new Promise(() => {}); + hangingFetch.preconnect = originalFetch.preconnect; + globalThis.fetch = hangingFetch as typeof fetch; + // Create more stories than can fit in viewport const posts = createMockPosts(20); ctx.app.setPostsForTesting(posts); @@ -139,6 +159,19 @@ describe("HackerNewsApp", () => { expect(storyListState.scroll.scrollTop).toBeGreaterThan(0); }); + it("scrollToStory moves an off-screen item into view from current layout", async () => { + const posts = createMockPosts(20); + ctx.app.setPostsForTesting(posts); + await ctx.renderOnce(); + + const storyListState = (ctx.app as any).storyListState; + expect(storyListState.scroll.scrollTop).toBe(0); + + scrollToStory(storyListState, 14); + + expect(storyListState.scroll.scrollTop).toBeGreaterThan(0); + }); + it("should scroll up to show off-screen selected story", async () => { // Create more stories than can fit in viewport const posts = createMockPosts(20);