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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **A global shortcut is first-come, first-served across the machine, and the loser gets no error.** The palette is on `Ctrl+Alt+P` because `Ctrl+Alt+Space` is taken by widely installed applications. `desktop::init` records what it could not take, and `unavailable_shortcuts` lets the front say so at startup — a log line is not an interface. `init` is also where every piece of native state is managed, before any command can run.
- **Every library operation reports, including when it changed nothing.** Exporting then re-importing at once imports zero notes (every id is already there) and that is correct; without `file.importedNothing` it is indistinguishable from a failure. Reports go to `StatusNotifier` (`core/services/notifications/`) and render under the titlebar — not inside the menu, which closes on the click and which a native dialog covers.
- **The File menu owns its entries.** `FileMenuComponent` declares them as an array and injects what they need (`LibraryStore`, `NoteSelectionStore`, `SpacesStore`, `ClockService`); the array's order is the order on screen. There were three contribution registries here — menu, settings pages, shortcut groups — so `layout/` could stay ignorant of a feature; with one feature they protected nothing and hid what a button did behind a registration, so they are gone. Same for the preferences panel, which lists its two pages, and the shortcuts sheet, which builds the global group and imports the notes ones from `notes/ui/notes-shortcuts.ts` — the canvas group is derived from the key table that binds it, so a key cannot be documented without being bound. The trash and tag management deliberately stay **out** of the menu — they are views on the notes, so they sit next to the quick filters and at the end of the tag rail respectively. "Préférences…" and "Quitter", which act on the application rather than on a tool, are static entries in the menu itself.
- **A silenced update is a version written down, not a "no".** `AppSettings.skippedUpdate` holds the version the user set aside from the prompt's checkbox; the release after it is a new offer and needs no gesture. `updateNotifications` is the blunter switch beside it, and both only silence the **silent** startup check — `checkNow()` always answers. ⚠️ The dot in the About menu reads `UpdateStore.hasPendingUpdate`, a third thing next to `UpdateStatus` and `CheckState`: `dismiss()` returns the status to `idle`, so a dot driven by it would disappear with the dialog it exists to outlive. That is also why `dismiss()` leaves the update standing and moves only the status.
- **A preference applies as it is typed, and three services carry it to the native side.** `SettingsStore` (`core/services/settings/`) writes through `PreferencesService` — one key per setting, never one serialised object — and talks to nobody; `GlobalShortcutsService`, `WindowBehaviorService` and `AutostartService` read its signals in an `effect` and push. There is no "OK / Annuler" anywhere in the panel, on purpose. ⚠️ Each of those `effect`s is built with an **explicit injector**: they start from `provideAppInitializer`, outside any constructor. And that initializer injects **everything before its first `await`** — an `inject()` after one is out of the injection context, and the app fails to bootstrap (NG0203) with a black window and nothing else.
- **The default shortcuts are a mirror, in two files.** `ShortcutBindings::defaults()` (`desktop.rs`) and `DEFAULT_SHORTCUTS` (`core/services/shortcuts/shortcut.model.ts`) carry the same three accelerators. The duplication is deliberate and commented on both sides: the native side takes them before the front has started, and without them `Ctrl+Alt+P` is dead for the length of the first render — exactly the second it is used from another application. `set_global_shortcuts` re-registers all three from scratch and **returns what it could not take**; the front is what says so.
- **A shortcut is captured, not typed.** `acceleratorFromEvent` reads `KeyboardEvent.code` (the physical key, so a combination set on AZERTY stays put on QWERTY) and refuses a keystroke with no modifier — a _global_ shortcut without one would swallow that key in every application on the machine. That refusal is also what leaves Tab and Escape working inside the field.
Expand Down
16 changes: 16 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,22 @@ update.
lifecycle that `UpdatePromptComponent.busy()` reads; the former is only what the menu has
left to announce. Its `idle` covers both "not checked yet" and "found something" — in the
second case the prompt is doing the talking.
- **"Later" can be made to stick, and what is written down is a version.** The prompt
carries a checkbox, because the decision belongs where the interruption happens.
`AppSettings.skippedUpdate` holds the version that was set aside — storing "the user said
no" would silence the release after it too, and a newer one is a new offer that needs no
gesture to become one again. `AppSettings.updateNotifications` is the blunter switch next
to it, and the preferences panel names the silenced version with a way to take it back,
since waiting for the next release is not one.
⚠️ Only the **silent** startup check is silenced. `checkNow()` is a question asked out
loud and always answers, which is also what stops the feature from being a trap.
- **The dot is a third thing, next to `UpdateStatus` and `CheckState`.** `hasPendingUpdate`
is "an update exists and is not installed", and the About menu wears it. It cannot read
`status()`: `dismiss()` returns that to `idle` — which is what closes the prompt — so a dot
driven by it would vanish with the dialog it exists to outlive. For the same reason
`dismiss()` leaves `_update` standing and moves only the status; `offered` is the computed
the prompt reads, and it is `null` exactly when the status is `idle`, so the dialog stays
up through the install it would otherwise disappear from.
- **The download does not cross the CSP.** It runs in Rust through the plugin's HTTP client,
not in the WebView, so pointing `endpoints` at GitHub needs no widening of `connect-src`.
- `bundle.createUpdaterArtifacts` makes the bundler emit a `.sig` beside **every** bundle it
Expand Down
18 changes: 16 additions & 2 deletions src/app/banners/update-prompt/update-prompt.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@if (store.update(); as update) {
@if (store.offered(); as update) {
<app-dialog [layer]="'update'" [dismissible]="!busy()" labelledBy="update-prompt-title" (closed)="later()">
<h2 id="update-prompt-title" class="update-title">
<span class="update-icon" aria-hidden="true">⬆</span>
Expand Down Expand Up @@ -30,8 +30,22 @@ <h2 id="update-prompt-title" class="update-title">
[attr.aria-label]="'update.progressLabel' | transloco"
></progress>
} @else {
<!-- The decision is offered where the interruption happens: a user who means to
update at the weekend should not have to find the preferences panel to stop
being asked every morning. -->
<label class="update-skip">
<input
type="checkbox"
class="update-skip-box"
data-testid="update-skip"
[checked]="skip()"
(change)="skip.set($any($event.target).checked)"
/>
{{ 'update.skipVersion' | transloco: { version: update.version } }}
</label>

<div class="update-actions">
<button type="button" class="update-btn" (click)="later()">
<button type="button" class="update-btn" data-testid="update-later" (click)="later()">
{{ 'update.later' | transloco }}
</button>
<button type="button" class="update-btn primary" (click)="install()">
Expand Down
20 changes: 20 additions & 0 deletions src/app/banners/update-prompt/update-prompt.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,23 @@ app-dialog {
color: var(--amber-ink);
}
}

.update-skip {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: var(--text-2);
cursor: pointer;
}

.update-skip-box {
flex: none;
accent-color: var(--amber);
cursor: pointer;
}

.update-skip-box:focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
37 changes: 37 additions & 0 deletions src/app/banners/update-prompt/update-prompt.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { beforeEach, describe, expect, it } from 'vitest';
import { SettingsStore } from '@core/services/settings/settings.store';
import { UpdateStore } from '@core/services/updates/update.store';
import { UpdaterService } from '@core/services/updates/updater.service';
import { FakeUpdater } from '@testing/fake-updater';
Expand Down Expand Up @@ -63,6 +64,42 @@ describe('UpdatePromptComponent', () => {
);
});

describe('the skip checkbox', () => {
const box = (): HTMLInputElement => fixture.nativeElement.querySelector('[data-testid="update-skip"]');

it('writes the version down, never a bare "no"', async () => {
await offerUpdate();

box().click();
await fixture.whenStable();
fixture.debugElement.query(By.css('[data-testid="update-later"]')).triggerEventHandler('click');
await fixture.whenStable();

expect(TestBed.inject(SettingsStore).skippedUpdate()).toBe('0.2.0');
});

it('leaves the version alone when the box is untouched', async () => {
await offerUpdate();

fixture.debugElement.query(By.css('[data-testid="update-later"]')).triggerEventHandler('click');
await fixture.whenStable();

expect(TestBed.inject(SettingsStore).skippedUpdate()).toBe('');
});

/** Escape and the backdrop produce no click on "Later", and must honour it too. */
it('is honoured by the ways out that are not the button', async () => {
await offerUpdate();

box().click();
await fixture.whenStable();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await fixture.whenStable();

expect(TestBed.inject(SettingsStore).skippedUpdate()).toBe('0.2.0');
});
});

it('installs nothing until the user says so', async () => {
await offerUpdate();

Expand Down
10 changes: 8 additions & 2 deletions src/app/banners/update-prompt/update-prompt.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { TranslocoPipe } from '@jsverse/transloco';
import { TranslationRef } from '@core/services/i18n/translation-ref.model';
import { UpdateStore } from '@core/services/updates/update.store';
Expand All @@ -18,6 +18,12 @@ import { DialogComponent } from '@shared/layout/dialog/dialog.component';
export class UpdatePromptComponent {
protected readonly store = inject(UpdateStore);

/**
* Local, and read on the way out: Escape and the backdrop close the dialog without
* touching the buttons, and both have to honour a box the user has already ticked.
*/
protected readonly skip = signal(false);

protected readonly busy = computed(
() => this.store.status() === 'installing' || this.store.status() === 'installed',
);
Expand All @@ -36,6 +42,6 @@ export class UpdatePromptComponent {
}

protected later(): void {
void this.store.dismiss();
void this.store.dismiss(this.skip());
}
}
11 changes: 8 additions & 3 deletions src/app/core/services/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@
"downloading": "Downloading…",
"downloadingPercent": "Downloading… {{percent}}%",
"restarting": "Restarting {{app}}…",
"progressLabel": "Download progress"
"progressLabel": "Download progress",
"skipVersion": "Do not offer version {{version}} again"
},
"about": {
"menuLabel": "About",
Expand All @@ -207,7 +208,8 @@
"close": "Close",
"whatsNew": "What's new",
"gettingStarted": "Getting started",
"shortcuts": "Keyboard shortcuts"
"shortcuts": "Keyboard shortcuts",
"updateWaiting": "An update is available"
},
"trash": {
"title": "Trash",
Expand Down Expand Up @@ -403,7 +405,10 @@
},
"notifications": {
"title": "Notifications",
"copyConfirmation": "Confirm every copy"
"copyConfirmation": "Confirm every copy",
"updates": "Tell me about updates",
"updateSkipped": "Version {{version}} will not be offered again.",
"updateSkippedForget": "Undo"
},
"variables": {
"intro": "Default values for your snippets' fields, across every note. What you type on a note wins; elsewhere the variable is offered in grey, never copied in.",
Expand Down
11 changes: 8 additions & 3 deletions src/app/core/services/i18n/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@
"downloading": "Téléchargement…",
"downloadingPercent": "Téléchargement… {{percent}} %",
"restarting": "Redémarrage de {{app}}…",
"progressLabel": "Avancement du téléchargement"
"progressLabel": "Avancement du téléchargement",
"skipVersion": "Ne plus me proposer la version {{version}}"
},
"about": {
"menuLabel": "À propos",
Expand All @@ -207,7 +208,8 @@
"close": "Fermer",
"whatsNew": "Nouveautés",
"gettingStarted": "Prise en main",
"shortcuts": "Raccourcis clavier"
"shortcuts": "Raccourcis clavier",
"updateWaiting": "Une mise à jour est disponible"
},
"trash": {
"title": "Corbeille",
Expand Down Expand Up @@ -403,7 +405,10 @@
},
"notifications": {
"title": "Notifications",
"copyConfirmation": "Accuser chaque copie"
"copyConfirmation": "Accuser chaque copie",
"updates": "Me signaler les mises à jour",
"updateSkipped": "La version {{version}} ne sera plus proposée.",
"updateSkippedForget": "Rétablir"
},
"variables": {
"intro": "Valeurs par défaut des champs de vos snippets, valables pour toutes les notes. Ce qui est saisi sur une note passe devant ; ailleurs, la variable est proposée en gris, jamais recopiée.",
Expand Down
10 changes: 10 additions & 0 deletions src/app/core/services/settings/app-settings.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export interface AppSettings {
readonly showPinnedFirst: boolean;
/** Acknowledges every clipboard copy under the titlebar. */
readonly copyConfirmation: boolean;
/** Whether the silent startup check may open the prompt at all. */
readonly updateNotifications: boolean;
/**
* The version the user said "later" to, or `''`. A **version** and not a boolean:
* remembering "no" would silence the release after it too, and a newer one is a new
* offer that needs no gesture to become one again.
*/
readonly skippedUpdate: string;
}

/**
Expand All @@ -57,6 +65,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
paletteShortcut: DEFAULT_SHORTCUTS.palette,
showPinnedFirst: true,
copyConfirmation: true,
updateNotifications: true,
skippedUpdate: '',
};

/** Derived rather than hand-written: the key **is** the field name, prefixed. */
Expand Down
25 changes: 25 additions & 0 deletions src/app/core/services/settings/settings.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ const asBoolean: SettingCodec<boolean> = {
format: String,
};

/** Free text, empty included — which is how "nothing is silenced" is written. */
const asText: SettingCodec<string> = {
parse: (stored) => stored.trim(),
format: (value) => value.trim(),
};

/** A blank accelerator would leave the palette unreachable without saying so. */
const asAccelerator: SettingCodec<string> = {
parse: (stored) => stored.trim() || null,
Expand Down Expand Up @@ -69,6 +75,8 @@ export class SettingsStore {
readonly paletteShortcut = this.setting('paletteShortcut', asAccelerator);
readonly showPinnedFirst = this.setting('showPinnedFirst', asBoolean);
readonly copyConfirmation = this.setting('copyConfirmation', asBoolean);
readonly updateNotifications = this.setting('updateNotifications', asBoolean);
readonly skippedUpdate = this.setting('skippedUpdate', asText);

/** What the OS asks for, followed live: a "system" theme must switch without a restart. */
private readonly systemPrefersDark = signal(false);
Expand Down Expand Up @@ -139,6 +147,23 @@ export class SettingsStore {
this.copyConfirmation.write(enabled);
}

/**
* Turning the prompt back on also forgets the version that was skipped: "notify me
* about updates" is exactly what taking a skip back means, and a user who has just
* asked for it should not have to find a second control.
*/
setUpdateNotifications(enabled: boolean): void {
this.updateNotifications.write(enabled);
if (enabled) {
this.skippedUpdate.write('');
}
}

/** `''` forgets the skip. Written by the prompt's checkbox, and by the panel. */
setSkippedUpdate(version: string): void {
this.skippedUpdate.write(version);
}

/**
* One setting: its signal, its restore step and its write-through, from a single
* declaration.
Expand Down
Loading