Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7bdb711
Stop saves from undoing spaces and line breaks typed in notes
lobbystack Sep 29, 2026
35f6882
Add Playwright end-to-end tests for the browser build
lobbystack Sep 29, 2026
9143894
Keep Cmd-K in the editor from also opening the command palette
lobbystack Sep 29, 2026
1d30274
Report raw merges, track body merges, and share body fixtures
lobbystack Sep 29, 2026
2ad6138
Show outside edits merged while saving a plain Markdown file
lobbystack Sep 29, 2026
27b5051
Rename the open file with F2 even when the caret is in its text
lobbystack Sep 29, 2026
e399fb7
Show the next tab after trashing the open document
lobbystack Sep 29, 2026
490b773
Move the caret to the text after Enter in the name, even while the ed…
lobbystack Sep 29, 2026
03d32ec
Open the last page and its document when the app starts or reloads
lobbystack Sep 29, 2026
eb03a07
Run a shortcut every time it is pressed, not once per 300 ms
lobbystack Sep 29, 2026
76b0c09
Keep Tasks, Projects and Calendar open when the page reloads
lobbystack Sep 29, 2026
a4188e1
Give tasks added on Home a due date of today
lobbystack Sep 29, 2026
24b9eeb
Fit the task pane toolbar and show statuses in words
lobbystack Sep 29, 2026
3bea289
Hide the tab bar's scrollbar and scroll it with the mouse wheel
lobbystack Sep 29, 2026
1250a09
Hide callout markers and name the callout type, like Obsidian
lobbystack Sep 29, 2026
b9abb99
Make code highlighting readable in light and dark themes
lobbystack Sep 29, 2026
4a46146
Show a check mark in checked task boxes
lobbystack Sep 29, 2026
af4e48f
Test typing in projects and opening tasks from the calendar and Home
lobbystack Sep 29, 2026
fe1fe92
Wait for a rename to finish before saving instead of failing
lobbystack Sep 29, 2026
36a0bda
Save typed text before closing its tab
lobbystack Sep 29, 2026
e28404f
Keep letters typed right after Cmd-O out of the open note
lobbystack Sep 29, 2026
46d50a4
Test that editing a note keeps its other properties and text
lobbystack Sep 29, 2026
27a889c
Type-check the end-to-end tests and find the built worker lazily
lobbystack Sep 29, 2026
71050ab
Merge end-to-end tests and the bugs they found
lobbystack Sep 29, 2026
bb75787
Use the merge status raw saves now return
lobbystack Sep 29, 2026
d1e789b
Make the end-to-end tests match real keyboards on Linux
lobbystack Sep 29, 2026
f5dc137
Drop unused lockfile entries flagged by two new advisories
lobbystack Sep 29, 2026
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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,38 @@ jobs:
- name: Generate the third-party license file
run: bun scripts/generate-third-party-licenses.ts --out "$RUNNER_TEMP/THIRD_PARTY_LICENSES.txt" --target x86_64-unknown-linux-gnu

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: { bun-version: 1.3.14 }
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with: { toolchain: '1.91', targets: wasm32-unknown-unknown }
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: taiki-e/install-action@76c2e6406e52637deed7160d77bded76bd83e06e # v2.87.14
with: { tool: wasm-bindgen-cli@0.2.127 }
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: bun-${{ runner.os }}-
- run: bun install --frozen-lockfile
- name: Install Chromium for Playwright
run: bun x playwright install --with-deps chromium
working-directory: apps/app
- name: Run end-to-end tests against the browser build
run: bun run --cwd apps/app test:e2e
- name: Keep traces of failed tests
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-traces
path: apps/app/test-results/
retention-days: 7

rust:
strategy:
fail-fast: false
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ crates/workspace-format/bindings/

# Agent worktrees
.claude/worktrees/
# Playwright output
test-results/
playwright-report/
4 changes: 3 additions & 1 deletion apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "bun run wasm:build && vite build",
"build:hosted": "bun run wasm:build && NOURA_HOSTED_BUILD=1 vite build",
"check": "bun run wasm:build && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "prettier --check src",
"lint": "prettier --check src tests",
"test:e2e": "playwright test",
"tauri": "tauri"
},
"dependencies": {
Expand Down Expand Up @@ -37,6 +38,7 @@
"jsdom": "30.0.1",
"@fontsource-variable/public-sans": "^5.3.0",
"@internationalized/date": "^3.12.0",
"@playwright/test": "1.62.1",
"@sveltejs/adapter-static": "latest",
"@sveltejs/kit": "latest",
"@sveltejs/vite-plugin-svelte": "latest",
Expand Down
47 changes: 47 additions & 0 deletions apps/app/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { defineConfig } from '@playwright/test';

const port = Number(process.env.NOURA_E2E_PORT ?? 4317);
const baseURL = `http://localhost:${port}`;

/**
* End-to-end tests drive the browser build of the app: the same shell,
* routes and editor as the desktop app, backed by the browser workspace
* worker and its origin-private file system. Every test gets a fresh browser
* context, so every test starts from an empty workspace list.
*/
export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/*.e2e.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
// One retry on CI marks a timing-sensitive test as flaky instead of failing
// the build; the report still lists it so it gets fixed.
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: process.env.CI ? [['list'], ['github']] : 'list',
use: {
baseURL,
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
// Chromium's own user agent, not a device preset: the app reads the
// platform to pick Cmd or Ctrl, and so does ControlOrMeta.
use: {
browserName: 'chromium',
viewport: { width: 1400, height: 900 },
},
},
],
webServer: {
command: `bun run build && bun x vite preview --port ${port} --strictPort`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
stdout: 'ignore',
stderr: 'pipe',
},
});
23 changes: 22 additions & 1 deletion apps/app/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
/* Code highlighting, readable on --muted in each theme. */
--syntax-keyword: #6f42c1;
--syntax-string: #116329;
--syntax-number: #953800;
--syntax-type: #0550ae;
--syntax-definition: #0a3069;
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
Expand Down Expand Up @@ -71,6 +77,11 @@
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--syntax-keyword: #ff7b72;
--syntax-string: #a5d6ff;
--syntax-number: #79c0ff;
--syntax-type: #ffa657;
--syntax-definition: #d2a8ff;
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
Expand Down Expand Up @@ -240,6 +251,7 @@
text-decoration: line-through;
}
.live-md .cm-md-checkbox {
position: relative;
display: inline-block;
width: 0.9rem;
height: 0.9rem;
Expand All @@ -250,9 +262,18 @@
vertical-align: text-bottom;
}
.live-md .cm-md-checkbox[aria-checked='true'] {
background: var(--primary);
background-color: var(--primary);
border-color: var(--primary);
}
/* The check mark takes the theme's contrast color, like the Checkbox. */
.live-md .cm-md-checkbox[aria-checked='true']::after {
content: '';
position: absolute;
inset: 0;
background-color: var(--primary-foreground);
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E")
center / 100% 100% no-repeat;
}
.live-md .cm-md-image {
display: block;
padding: 0.35rem 0;
Expand Down
17 changes: 16 additions & 1 deletion apps/app/src/lib/app-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { expect, test } from 'bun:test';
import { shortcutCommand, type ShortcutEvent } from './app-shortcuts';
import {
shortcutCommand,
shortcutOnce,
type ShortcutEvent,
} from './app-shortcuts';

test('a menu report of the same press runs once; repeated presses all run', () => {
const once = shortcutOnce(300);
expect(once('close-tab', 'key', 0)).toBe(true);
expect(once('close-tab', 'menu', 10)).toBe(false);
expect(once('close-tab', 'key', 100)).toBe(true);
expect(once('close-tab', 'key', 150)).toBe(true);
expect(once('close-tab', 'menu', 500)).toBe(true);
expect(once('close-tab', 'key', 520)).toBe(false);
expect(once('new-note', 'menu', 530)).toBe(true);
});

function press(key: string, extra: Partial<ShortcutEvent> = {}): ShortcutEvent {
return {
Expand Down
21 changes: 21 additions & 0 deletions apps/app/src/lib/app-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ export function shortcutCommand(
return GO_TO[key] ?? null;
}

/** Where a command came from: a key press or the macOS menu bar. */
export type ShortcutSource = 'key' | 'menu';

/**
* On a Mac the menu bar and the key press can both report one shortcut.
* The returned check lets the second report of a pair through only when it
* comes from the same source, so pressing a shortcut twice quickly (Cmd-W
* to close two tabs) still runs it twice.
*/
export function shortcutOnce(windowMs = 300) {
const last = new Map<string, { at: number; source: ShortcutSource }>();
return (command: string, source: ShortcutSource, now: number): boolean => {
const previous = last.get(command);
if (previous && previous.source !== source && now - previous.at < windowMs)
return false;
last.set(command, { at: now, source });
return true;
};
}

/** Shortcuts as listed in Settings, in the order they are shown. */
export const APP_SHORTCUTS: ReadonlyArray<{
label: string;
Expand All @@ -62,6 +82,7 @@ export const APP_SHORTCUTS: ReadonlyArray<{
{ label: 'Open a file by name', keys: ['Mod', 'O'] },
{ label: 'Search file contents', keys: ['Mod', 'Shift', 'F'] },
{ label: 'Search and run commands', keys: ['Mod', 'K'] },
{ label: 'Rename the open file', keys: ['F2'] },
{ label: 'Close tab', keys: ['Mod', 'W'] },
{ label: 'Next tab', keys: ['Ctrl', 'Tab'] },
{ label: 'Previous tab', keys: ['Ctrl', 'Shift', 'Tab'] },
Expand Down
60 changes: 60 additions & 0 deletions apps/app/src/lib/command-palette.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,31 @@ type CommandPaletteMode = 'all' | 'files' | 'search';
class CommandPaletteStore {
open = $state(false);
mode = $state<CommandPaletteMode>('all');
/** Where focus was before the palette opened, to return it on close. */
returnFocus: HTMLElement | null = null;
#stopTypeAhead: (() => void) | null = null;
/** Characters typed before the palette's field took focus. */
#typedAhead = '';
#receiver: ((text: string) => void) | null = null;

/**
* The palette adds characters typed before its field had focus to its
* query through `receive`, including any typed before it mounted.
*/
receiveTypedAhead(receive: (text: string) => void): () => void {
this.#receiver = receive;
this.#deliver();
return () => {
if (this.#receiver === receive) this.#receiver = null;
};
}

#deliver() {
if (!this.#receiver || !this.#typedAhead) return;
const text = this.#typedAhead;
this.#typedAhead = '';
this.#receiver(text);
}

toggle() {
if (this.open) this.close();
Expand All @@ -20,12 +45,47 @@ class CommandPaletteStore {

close() {
this.open = false;
this.#stopTypeAhead?.();
}

show(mode: CommandPaletteMode = 'all') {
this.mode = mode;
if (!this.open) this.#takeKeys();
this.open = true;
}

/**
* The palette's field takes focus a moment after the palette opens, and
* people start typing right after the shortcut. Until the field has
* focus, the element that had it lets go, so those keys cannot edit the
* document behind the palette, and the palette collects them instead.
*/
#takeKeys() {
if (typeof document === 'undefined') return;
this.#stopTypeAhead?.();
const active = document.activeElement;
this.returnFocus =
active instanceof HTMLElement && active !== document.body ? active : null;
this.returnFocus?.blur();
this.#typedAhead = '';
const collect = (event: KeyboardEvent) => {
if (event.target !== document.body) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key.length !== 1) return;
event.preventDefault();
this.#typedAhead += event.key;
this.#deliver();
};
const stop = () => {
window.removeEventListener('keydown', collect, true);
document.removeEventListener('focusin', stop, true);
this.#stopTypeAhead = null;
};
window.addEventListener('keydown', collect, true);
// Once anything takes focus, keys go where they belong again.
document.addEventListener('focusin', stop, true);
this.#stopTypeAhead = stop;
}
}

export const commandPalette = new CommandPaletteStore();
20 changes: 11 additions & 9 deletions apps/app/src/lib/components/app-shortcuts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import { page } from '$app/state';
import { onMount } from 'svelte';
import { subscribeAppMenu, type AppMenuCommand } from '@noura/workspace';
import { shortcutCommand } from '$lib/app-shortcuts';
import {
shortcutCommand,
shortcutOnce,
type ShortcutSource,
} from '$lib/app-shortcuts';
import { commandPalette } from '$lib/command-palette.svelte';
import { createFolder, createNote } from '$lib/file-actions';
import { hostOs } from '$lib/host-os';
Expand All @@ -29,13 +33,11 @@
};

// On a Mac the menu bar and the key press can both report one shortcut;
// run each command once.
const lastRun: Partial<Record<AppMenuCommand, number>> = {};
// run each press once.
const once = shortcutOnce();

async function run(command: AppMenuCommand) {
const now = performance.now();
if (now - (lastRun[command] ?? -Infinity) < 300) return;
lastRun[command] = now;
async function run(command: AppMenuCommand, source: ShortcutSource) {
if (!once(command, source, performance.now())) return;
const path = ROUTES[command];
if (path) {
const pluginId = routePlugin(path);
Expand Down Expand Up @@ -81,14 +83,14 @@
const command = shortcutCommand(event, os);
if (!command) return;
event.preventDefault();
void run(command);
void run(command, 'key');
}

onMount(() => {
if (getAppPlatform() !== 'desktop') return;
let disposed = false;
let unsubscribe: (() => void) | undefined;
void subscribeAppMenu((command) => void run(command))
void subscribeAppMenu((command) => void run(command, 'menu'))
.then((stop) => {
if (disposed) stop();
else unsubscribe = stop;
Expand Down
Loading
Loading