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 @@ -73,6 +73,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **Input is validated in the model, not just in the form.** `error.rs` carries `ValidationError`; `SpaceDraft::validated_name` and `spaces::model::validate_move_target` run before locking. A rule held only by a form is not held. Language needs no validation any more — an unknown value fails deserialisation at the bridge, and cannot be written on the front at all.
- **Data-source seam.** Components and stores never touch a data source directly: everything goes through `NotesRepository` / `SpacesRepository`, plain `providedIn: 'root'` classes — no interface, no `InjectionToken`, nothing bound in `app.config.ts`, because there is exactly one implementation. Specs substitute them by class (`{ provide: NotesRepository, useValue: fake }`, via `provideAppTesting()`); the fakes in `src/testing/` keep their compile-time check with `implements Pick<NotesRepository, keyof NotesRepository>`. Don't call a generated command from a component or a store — the repositories are the only callers. `NotesRepository` has **no method returning a raw note list** — that's on purpose, one would invite re-filtering on the front.
- **A conversion exists only where the wire shape differs from the model shape.** `model/` is the vocabulary the app reasons in, `data/` is the boundary: the generated wire types (imported under a `Wire*` name, never re-declared), the repository, the conversion. Notes need theirs (`Date` ↔ ISO, patch copied field by field) and it lives in `core/data/note.mapper.ts`. Spaces don't: `Space` crosses the bridge as itself. Don't reintroduce an identity mapper for symmetry. The section key no longer needs a runtime guard either — `NoteSectionKey` is generated, so a variant added in Rust is a compile error.
- **A space can be pinned, and that is the only order there is to choose.** `spaces.pinned` (migration 9) hoists it to the head of `list_spaces`, which then falls back on `name COLLATE NOCASE` — the same shape the canvas gives notes. A `position` column was the alternative and was refused: an order the user maintains is a second thing to keep consistent on every insert and delete, where pinning is a boolean and a gesture the application already has. ⚠️ `Space.pinned` carries `#[serde(default)]` so an export file written before the column stays readable, and specta turns that into an **optional** key — `SpacesRepository` is where it becomes the boolean the model requires.
- **`null` space means "all spaces".** `SpacesStore.activeSpaceId()` is `null` when the user wants every space, and that is a choice, not a loading state — don't add an "All" row to the spaces data, notes would end up filed into it. A note always has a `spaceId`; creating one with no space available is refused on purpose.
- **Deleting a space needs a refuge.** `notes.space_id` carries `ON DELETE CASCADE`, so `delete_space(id, targetSpaceId)` moves the notes _then_ deletes, in one transaction — there is no one-argument variant, which would have made data loss the default. It leaves `updated_at` alone (the canvas sorts on it, and touching it would float the whole absorbed space to the top). A space can't be its own refuge: `spaces::model::validate_move_target` refuses it before any SQL runs. `targetSpaceId` is the first multi-word command argument, so it's the one that actually exercises Tauri's camelCase renaming.
- **"À trier" = a note with a deadline.** The `untriaged` filter, the `⏳` badge and the "à trier bientôt" section hint all read the same field, `lifecycle`. It's set from the editor's date field, converted to the **end of the local day** (`endOfLocalDay`) — midnight would make a note dated today expired on the spot — and read back in local time too. Remove that field and all three affordances go permanently empty, which is exactly the state they were in before it existed.
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1514,7 +1514,7 @@ The commands, grouped by the feature that owns them:
| Feature | Commands |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `notes` | `query_notes`, `create_note`, `update_note`, `delete_note`, `delete_notes`, `restore_notes`, `list_trash`, `purge_notes`, `empty_trash`, `move_notes`, `tag_notes`, `list_tags`, `rename_tag`, `merge_tags`, `delete_tag`, `fill_placeholders`, `set_placeholder_values` |
| `spaces` | `list_spaces`, `create_space`, `rename_space`, `delete_space` |
| `spaces` | `list_spaces`, `create_space`, `rename_space`, `pin_space`, `delete_space` |
| `attachments` | `attach_file`, `attach_clipboard_image`, `list_attachments`, `read_attachment`, `open_attachment`, `save_attachment`, `delete_attachment` |
| `transfer` | `export_notes`, `export_selection`, `import_notes`, `share_notes` |
| `desktop` | `sync_tray`, `unavailable_shortcuts` |
Expand Down Expand Up @@ -2170,8 +2170,8 @@ The numeric prefix on each file is therefore load-bearing: it is the run order.
that resolves the seeded space — `homeSpaceId()` records it while exactly one exists and writes
it to a marker file, because WebdriverIO gives each spec file its own worker process and a
module-level cache would be empty again in the next one. ⚠️ It cannot be `listSpaces()[0]`:
`list_spaces` orders by `name COLLATE NOCASE`, so after another file creates `Ops` the first
row is no longer the seeded space.
`list_spaces` orders pinned first and then by `name COLLATE NOCASE`, so after another file
creates `Ops` — or pins anything — the first row is no longer the seeded space.

**⚠️ There is no restart, and no spec may claim one.** `reopenSession()` is a
`browser.reloadSession()`: it tears the session down and opens a new one against the same
Expand Down
12 changes: 12 additions & 0 deletions e2e/pageobjects/overlays.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export const spaces = {
* off a template ref — but the day that control becomes signal-bound, the driver's
* missing `change` would silently delete with the wrong refuge.
*/
/** From the same panel as the rename and the delete, which the ⋯ opens. */
/**
* ⚠️ Closes behind itself. The edit panel **replaces** the menu rather than sitting
* over it, so the dropdown is still there afterwards and `open()` — which checks for
* exactly that — would do nothing, leaving the next caller in the panel.
*/
async togglePin(id: string): Promise<void> {
await $(`${testid('space-edit')}[data-space-id="${id}"]`).click();
await $(testid('space-pin')).click();
await spaces.close();
},

async remove(id: string, refugeId: string): Promise<void> {
await $(`${testid('space-edit')}[data-space-id="${id}"]`).click();
await setNativeValue(testid('space-move-target'), refugeId);
Expand Down
62 changes: 62 additions & 0 deletions e2e/specs/05-spaces.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,68 @@ describe('Spaces', () => {
expect(after?.name).toBe('Lectures');
});

/**
* Name order was the only order there had ever been, so the space opened
* every morning sat wherever its initial fell. Pinning hoists it, the way it does a
* note on the canvas — and the order is SQL's, which only a real database can prove.
*
* ⚠️ Its own space, named to sort **last**: Mocha runs a nested suite after its
* siblings, and by then the tests above have renamed and deleted theirs.
*/
describe('pinning one to the head of the list', () => {
const name = 'Zzz pinned';
let id = '';

before(async () => {
id = (await bridge.createSpace({ name })).id;
// Written straight through the bridge, so the front end has never heard of it.
await reloadCanvas();
});

after(async () => {
const refuge = (await bridge.listSpaces()).find((space) => space.id !== id);
await bridge.deleteSpace(id, refuge!.id);
});

it('hoists it whatever its name, and lets it fall back', async () => {
expect((await bridge.listSpaces()).at(-1)?.id).toBe(id);

await spaces.open();
await spaces.togglePin(id);
await browser.pause(500);

const pinned = await bridge.listSpaces();
expect(pinned[0]?.id).toBe(id);
expect(pinned[0]?.pinned).toBe(true);

await spaces.open();
await spaces.togglePin(id);
await browser.pause(500);

const loose = await bridge.listSpaces();
expect(loose.at(-1)?.id).toBe(id);
expect(loose.at(-1)?.pinned).toBe(false);
});

/**
* ⚠️ A rename answers with the row it read back rather than with what it was sent,
* which is what keeps it from quietly reporting a pinned space as unpinned.
*/
it('survives a rename', async () => {
await spaces.open();
await spaces.togglePin(id);
await browser.pause(500);

await spaces.open();
await spaces.rename(id, 'Zzz renamed');
await browser.pause(500);

const after = (await bridge.listSpaces()).find((space) => space.id === id);
expect(after?.name).toBe('Zzz renamed');
expect(after?.pinned).toBe(true);
});
});

it('filters the canvas down to the active space', async () => {
const target = (await bridge.listSpaces()).find((space) => space.name === 'Lectures')!;
await bridge.createNote(draft({ spaceId: target.id, title: 'Only in Lectures' }));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE spaces DROP COLUMN pinned;
7 changes: 7 additions & 0 deletions src-tauri/migrations/2026-09-15-000009_pinned_spaces/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Spaces came back in one order and only one: `name COLLATE NOCASE`. The space opened
-- every morning sat wherever its initial put it, under archives nobody touches.
--
-- A boolean and not a `position` column: an order the user maintains is a second thing
-- to keep consistent on every insert and delete, and pinning is the gesture the app
-- already has for "keep this within reach".
ALTER TABLE spaces ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
1 change: 1 addition & 0 deletions src-tauri/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ diesel::table! {
spaces (id) {
id -> Text,
name -> Text,
pinned -> Bool,
}
}

Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ use notes::{
query_notes, rename_tag, restore_notes, set_global_placeholders, set_placeholder_values,
tag_notes, update_note,
};
use spaces::{create_space, delete_space, list_spaces, rename_space};
use spaces::{create_space, delete_space, list_spaces, pin_space, rename_space};
use transfer::{export_notes, export_selection, import_notes, share_notes};

/// Resolved from the manifest and not from the current directory: a relative path
Expand Down Expand Up @@ -78,6 +78,7 @@ fn ipc_builder() -> Builder<tauri::Wry> {
list_spaces,
create_space,
rename_space,
pin_space,
delete_space,
attach_file,
attach_clipboard_image,
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/spaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ pub fn rename_space(id: String, draft: SpaceDraft, db: State<'_, Db>) -> Result<
Ok(store::rename(&mut connection, &id, &name)?)
}

/// Hoists a space to the head of the list, or lets it fall back among the others.
#[tauri::command(async)]
#[specta::specta]
pub fn pin_space(id: String, pinned: bool, db: State<'_, Db>) -> Result<Space, AppError> {
let mut connection = lock(&db)?;

Ok(store::set_pinned(&mut connection, &id, pinned)?)
}

#[tauri::command(async)]
#[specta::specta]
pub fn delete_space(
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/spaces/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub struct Space {
pub id: String,
/// Uniqueness is case-insensitive, decided by persistence.
pub name: String,
/// Hoisted to the head of the list, the way a pinned note is on the canvas.
#[serde(default)]
pub pinned: bool,
}

#[derive(Debug, Clone, Deserialize, Type)]
Expand Down
47 changes: 39 additions & 8 deletions src-tauri/src/spaces/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,51 @@ use uuid::Uuid;
/// An empty list is valid: it is the state of the first launch.
pub fn list(connection: &mut SqliteConnection) -> Result<Vec<Space>, StorageError> {
let rows = spaces::table
.select((spaces::id, spaces::name))
.select((spaces::id, spaces::name, spaces::pinned))
// Pinned first, then by name — the same shape the canvas gives notes.
.order(spaces::pinned.desc())
// Raw fragment: Diesel does not model collations, and sorting as BINARY
// would place "personal" after "Zebra".
.order(sql::<Text>("name COLLATE NOCASE"))
.load::<(String, String)>(connection)?;
.then_order_by(sql::<Text>("name COLLATE NOCASE"))
.load::<(String, String, bool)>(connection)?;

Ok(rows
.into_iter()
.map(|(id, name)| Space { id, name })
.map(|(id, name, pinned)| Space { id, name, pinned })
.collect())
}

/// Reads one back, so a write answers with the row rather than with what it sent —
/// a rename must not quietly drop whether the space was pinned.
fn find(connection: &mut SqliteConnection, id: &str) -> Result<Space, StorageError> {
spaces::table
.find(id)
.select((spaces::id, spaces::name, spaces::pinned))
.first::<(String, String, bool)>(connection)
.optional()?
.map(|(id, name, pinned)| Space { id, name, pinned })
.ok_or_else(|| StorageError::SpaceNotFound(id.to_string()))
}

/// Hoists a space to the head of the list, or lets it fall back among the others.
pub fn set_pinned(
connection: &mut SqliteConnection,
id: &str,
pinned: bool,
) -> Result<Space, StorageError> {
connection.transaction(|connection| {
if !exists(connection, id)? {
return Err(StorageError::SpaceNotFound(id.to_string()));
}

diesel::update(spaces::table.find(id))
.set(spaces::pinned.eq(pinned))
.execute(connection)?;

find(connection, id)
})
}

/// The foreign key would catch it too, but with an unreadable SQLite message
/// whereas the front end displays the error.
pub fn exists(connection: &mut SqliteConnection, id: &str) -> Result<bool, StorageError> {
Expand Down Expand Up @@ -74,6 +107,7 @@ pub fn create(connection: &mut SqliteConnection, name: &str) -> Result<Space, St
let space = Space {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
pinned: false,
};

diesel::insert_into(spaces::table)
Expand All @@ -100,10 +134,7 @@ pub fn rename(
.set(spaces::name.eq(name))
.execute(connection)?;

Ok(Space {
id: id.to_string(),
name: name.to_string(),
})
find(connection, id)
})
}

Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/transfer/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ mod tests {
spaces: vec![Space {
id: "s-1".to_string(),
name: "Personal".to_string(),
pinned: false,
}],
notes: vec![sample()],
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/transfer/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ mod tests {
spaces: vec![Space {
id: "s-1".to_string(),
name: "Personal".to_string(),
pinned: false,
}],
notes: vec![sample()],
};
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/tests/ipc_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,14 @@ fn a_space_serializes_with_the_keys_the_front_reads() {
let json = serde_json::to_value(Space {
id: "s-1".to_string(),
name: "Personal".to_string(),
pinned: true,
})
.unwrap();

assert_eq!(json, serde_json::json!({ "id": "s-1", "name": "Personal" }));
assert_eq!(
json,
serde_json::json!({ "id": "s-1", "name": "Personal", "pinned": true })
);
}

#[test]
Expand Down Expand Up @@ -441,6 +445,7 @@ fn an_export_bundle_reads_back_the_notes_it_wrote() {
spaces: vec![Space {
id: "s-1".to_string(),
name: "Personal".to_string(),
pinned: false,
}],
notes: vec![sample()],
};
Expand Down
65 changes: 64 additions & 1 deletion src-tauri/tests/spaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use diesel::prelude::*;
use devbox_lib::db::open_in_memory;
use devbox_lib::db::schema::notes;
use devbox_lib::error::StorageError;
use devbox_lib::spaces::store::{create, delete, exists, list, rename};
use devbox_lib::spaces::store::{create, delete, exists, list, rename, set_pinned};

const T0: &str = "2026-07-25T09:00:00.000Z";

Expand Down Expand Up @@ -69,6 +69,69 @@ fn spaces_are_listed_in_name_order() {
assert_eq!(names(&mut connection), ["Boulot", "personal", "Veille"]);
}

/// Name order alone put the space opened every morning wherever its initial fell,
/// under archives nobody touches. Pinning is the gesture the app already has for
/// "keep this within reach", and it hoists here the way it does on the canvas.
#[test]
fn a_pinned_space_comes_first_whatever_its_name() {
let mut connection = open_in_memory().unwrap();
create(&mut connection, "Boulot").unwrap();
let veille = create(&mut connection, "Veille").unwrap();
create(&mut connection, "Archive").unwrap();

set_pinned(&mut connection, &veille.id, true).unwrap();

assert_eq!(names(&mut connection), ["Veille", "Archive", "Boulot"]);
}

#[test]
fn pinned_spaces_are_still_sorted_among_themselves() {
let mut connection = open_in_memory().unwrap();
let veille = create(&mut connection, "Veille").unwrap();
let boulot = create(&mut connection, "Boulot").unwrap();
create(&mut connection, "Archive").unwrap();

set_pinned(&mut connection, &veille.id, true).unwrap();
set_pinned(&mut connection, &boulot.id, true).unwrap();

assert_eq!(names(&mut connection), ["Boulot", "Veille", "Archive"]);
}

#[test]
fn unpinning_lets_a_space_fall_back_among_the_others() {
let mut connection = open_in_memory().unwrap();
let veille = create(&mut connection, "Veille").unwrap();
create(&mut connection, "Archive").unwrap();

set_pinned(&mut connection, &veille.id, true).unwrap();
let unpinned = set_pinned(&mut connection, &veille.id, false).unwrap();

assert!(!unpinned.pinned);
assert_eq!(names(&mut connection), ["Archive", "Veille"]);
}

/// ⚠️ A rename answers with the row it read back, not with what it was sent — which
/// is what keeps it from quietly reporting a pinned space as unpinned.
#[test]
fn renaming_a_pinned_space_leaves_it_pinned() {
let mut connection = open_in_memory().unwrap();
let space = create(&mut connection, "Veille").unwrap();
set_pinned(&mut connection, &space.id, true).unwrap();

let renamed = rename(&mut connection, &space.id, "Veille technique").unwrap();

assert!(renamed.pinned);
}

#[test]
fn pinning_a_space_that_is_gone_says_which_one() {
let mut connection = open_in_memory().unwrap();

let error = set_pinned(&mut connection, "missing", true).unwrap_err();

assert!(matches!(error, StorageError::SpaceNotFound(id) if id == "missing"));
}

#[test]
fn a_duplicate_name_is_refused_regardless_of_case() {
let mut connection = open_in_memory().unwrap();
Expand Down
Loading