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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 4 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -673,7 +677,6 @@ export class HackerNewsApp {
}

if (this.renderer.isDestroyed) return;
scrollToStory(this.storyListState, index);
this.saveToCache();
}

Expand Down
4 changes: 4 additions & 0 deletions src/components/StoryList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions src/test/app.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Response>(() => {});
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);
Expand All @@ -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);
Expand Down
Loading