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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ cli.ts CLI entry (commander). Detects jj/git, builds DiffArgs
starts the API server, spawns Vite in --dev mode.
server/
main.ts Hono server. GET /api/diff; POST/DELETE /api/viewed;
POST/DELETE /api/comment; serves dist/web statics.
POST/DELETE /api/comment; GET/POST /api/theme;
GET /api/theme.js (pre-first-paint theme boot script
loaded by index.html); serves dist/web statics.
settings.ts Global (not per-repo) UI settings — currently just the
theme — in ~/.local/share/skepsis/settings.json.
diff.ts Runs `jj diff --git`/`git diff`, extracts per-file blob
hashes from index lines.
viewed.ts Viewed state, content-addressed by git blob ID — files
Expand Down
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>skepsis</title>
<!-- Sets data-theme from the stored preference. Deliberately a classic
(render-blocking) script so a forced theme applies before first
paint. Served by the API server; reaches it via the Vite proxy in
dev. -->
<script src="/api/theme.js"></script>
</head>
<body>
<div id="root"></div>
Expand Down
21 changes: 21 additions & 0 deletions server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@ import { getFileContents } from './fileContents.ts'
import { loadViewed, markViewed, unmarkViewed, unmarkViewedAll } from './viewed.ts'
import { insertComment, removeComment } from './comment.ts'
import { getCommentSyntaxes } from './commentSyntax.ts'
import { loadTheme, saveTheme, themeBootScript } from './settings.ts'
import {
viewedRequestSchema,
viewedDeleteSchema,
viewedDeleteAllSchema,
commentRequestSchema,
commentDeleteSchema,
fileContentsQuerySchema,
themeRequestSchema,
} from '../shared/types.ts'
import type {
DiffResponse,
FileContentsResponse,
OkResponse,
ErrorResponse,
ThemeResponse,
} from '../shared/types.ts'

export async function startServer(opts: {
Expand Down Expand Up @@ -99,6 +102,24 @@ export async function startServer(opts: {
return c.json({ ok: true } satisfies OkResponse)
})

app.get('/api/theme', async (c) => {
return c.json({ theme: await loadTheme() } satisfies ThemeResponse)
})

// Loaded by a render-blocking <script> in index.html (see themeBootScript).
// no-store: a cached copy would re-apply an old preference before React
// corrects it, flashing the wrong theme — the exact problem this solves.
app.get('/api/theme.js', async (c) => {
c.header('Cache-Control', 'no-store')
c.header('Content-Type', 'text/javascript')
return c.body(themeBootScript(await loadTheme()))
})

app.post('/api/theme', zValidator('json', themeRequestSchema), async (c) => {
await saveTheme(c.req.valid('json').theme)
return c.json({ ok: true } satisfies OkResponse)
})

// Keep this path valid from source (`server/`) and from the published bundle
// (`dist/cli.js`): both are one directory below the package root.
const distDir = join(import.meta.dirname, '..', 'dist', 'web')
Expand Down
67 changes: 67 additions & 0 deletions server/settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
// Type-only, so it doesn't evaluate the module before HOME is stubbed.
import type * as SettingsModule from './settings.ts'

// The module derives its storage path from HOME at import time, so stub HOME
// and import dynamically instead of statically.
let home: string
let settings: typeof SettingsModule

beforeAll(async () => {
home = await mkdtemp(join(tmpdir(), 'skepsis-settings-'))
vi.stubEnv('HOME', home)
settings = await import('./settings.ts')
})

afterAll(async () => {
vi.unstubAllEnvs()
await rm(home, { recursive: true, force: true })
})

describe('loadTheme/saveTheme', () => {
it('defaults to system when the file is missing', async () => {
expect(await settings.loadTheme()).toBe('system')
})

it('round-trips a saved theme', async () => {
await settings.saveTheme('light')
expect(await settings.loadTheme()).toBe('light')
await settings.saveTheme('system')
expect(await settings.loadTheme()).toBe('system')
})

it('falls back to system on garbage', async () => {
const path = join(home, '.local', 'share', 'skepsis', 'settings.json')
await mkdir(join(home, '.local', 'share', 'skepsis'), { recursive: true })
await writeFile(path, 'not json')
expect(await settings.loadTheme()).toBe('system')
await writeFile(path, JSON.stringify({ theme: 'mauve' }))
expect(await settings.loadTheme()).toBe('system')
})
})

describe('themeBootScript', () => {
it('stamps a forced theme onto the html element', () => {
expect(settings.themeBootScript('light')).toBe(
"document.documentElement.dataset.theme = 'light'\n",
)
expect(settings.themeBootScript('dark')).toBe(
"document.documentElement.dataset.theme = 'dark'\n",
)
})

it('is empty for system', () => {
expect(settings.themeBootScript('system')).toBe('')
})
})
49 changes: 49 additions & 0 deletions server/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

/**
* Global UI settings at ~/.local/share/skepsis/settings.json. Unlike viewed
* state, settings are not per-repo (no cwd hashing): a theme preference is
* about the user, not the diff under review. Server-side storage is what
* makes the preference survive restarts at all — the ephemeral port means a
* fresh browser origin every run, so web storage can't.
*/

import { join } from 'path'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { THEME_MODES } from '../shared/types.ts'
import type { ThemeMode } from '../shared/types.ts'

const BASE_DIR = join(process.env['HOME'] ?? '~', '.local', 'share', 'skepsis')
const SETTINGS_PATH = join(BASE_DIR, 'settings.json')

/** Missing file, unparseable JSON, or an unknown value → 'system'. */
export async function loadTheme(): Promise<ThemeMode> {
try {
const raw: unknown = JSON.parse(await readFile(SETTINGS_PATH, 'utf-8'))
const theme = (raw as { theme?: unknown }).theme
if ((THEME_MODES as readonly unknown[]).includes(theme)) return theme as ThemeMode
} catch {
// fall through to the default
}
return 'system'
}

export async function saveTheme(theme: ThemeMode): Promise<void> {
await mkdir(BASE_DIR, { recursive: true })
await writeFile(SETTINGS_PATH, JSON.stringify({ theme }, null, 2) + '\n')
}

/** Body of /api/theme.js, loaded by a render-blocking <script> in index.html
* so a forced theme's data-theme lands on <html> before first paint (CSS
* keys color-scheme off the attribute). 'system' means no attribute: an
* empty script leaves the page following the OS. */
export function themeBootScript(theme: ThemeMode): string {
if (theme === 'system') return ''
return `document.documentElement.dataset.theme = '${theme}'\n`
}
14 changes: 14 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ export type ViewedRequest = z.infer<typeof viewedRequestSchema>
export type CommentRequest = z.infer<typeof commentRequestSchema>
export type CommentDeleteRequest = z.infer<typeof commentDeleteSchema>

// --- UI types ---

/** Color scheme. 'system' follows the OS preference; 'light' and 'dark' force
* it (e.g. light diffs on a dark desktop). One list drives the API schema,
* the toggle cycle, and the client's data-theme validation. */
export const THEME_MODES = ['light', 'dark', 'system'] as const
export type ThemeMode = (typeof THEME_MODES)[number]

export const themeRequestSchema = z.object({ theme: z.enum(THEME_MODES) })

export interface ThemeResponse {
theme: ThemeMode
}

// --- VCS types ---

/**
Expand Down
Loading
Loading