Skip to content

Latest commit

 

History

History
473 lines (366 loc) · 19.4 KB

File metadata and controls

473 lines (366 loc) · 19.4 KB

AGENTS.md — gpui-base-template (Daynote)

Agent instructions for this repository. Layer rules are defined in ARCHITECTURE.md.

Project overview

gpui-base-template — GPUI desktop app (branded Daynote) with sidebar navigation, daily notes, notes list, gpui-component UI, i18n (English/Indonesian), theme toggle, and layered feature architecture (view + action + style).

Runtime: native desktop (macOS primary target; Linux/Windows via cross-compile + bundle scripts)

Binary name: Daynote

Crate name: gpui-base-template

Data: notes and embedded images are encrypted at rest (AES-256-GCM) under the app data folder; the master key lives in the OS secure store (macOS Keychain, Windows Credential Manager, Linux Secret Service). Export decrypts in-app to readable Markdown/TXT/ZIP.

Verify before finishing

cargo build
python3 scripts/check-gpui-render-updates.py
python3 scripts/format-rust-guard-spacing.py --check
cargo run

Run cargo build after every structural change. Run python3 scripts/format-rust-guard-spacing.py to fix guard/loop spacing. Run python3 scripts/check-gpui-render-updates.py when adding or changing views, routers, or shell state. Manually smoke-test language and theme toggles in Settings when touching locale or theme code.

GPUI entity updates during render (do not regress)

DefaultLayout is a GPUI Entity. While it is rendering (including any notes::render, settings::render, or *_view.rs helpers called from DefaultLayout::render), calling shell.update(cx, |layout, …|) on that same entity panics:

cannot update DefaultLayout while it is already being updated
Situation Do this
Need mutable shell state while building UI Precompute in DefaultLayout::render via &mut self (e.g. prepare_*() helpers), then pass &DefaultLayout into child render()
Need cached / derived data in a page view Read-only getters on &DefaultLayout — never shell.update in view.rs / *_view.rs / router/mod.rs
Need to sync inputs or run side effects when a screen paints window.defer(cx, …) then shell.update inside the deferred closure
Button / switch / async path / folder picker shell.update in action.rs or on_* handlers is fine (not inside render)
Multiple mutations in one async callback One shell.update block — never nest shell.update inside another shell.update

CI / pre-commit guard: python3 scripts/check-gpui-render-updates.py fails if shell.update( appears under **/view.rs, **/*_view.rs, router/mod.rs, or layouts/default/view.rs. Install hooks once: ./scripts/install-githooks.sh (sets core.hooksPath=githooks; pre-commit also runs guard spacing --check).

Manual smoke after shell/view changes: navigate across all sidebar screens; open any calendar-style notes views if present. App must not abort on navigation.

See ARCHITECTURE.md (GPUI shell entity during render) for the same rules in layer terms.

For release/bundle work:

make build-release
make bundle          # host OS, release
make bundle-macos    # macOS .app

Directory structure

Layering: thin router, pages under components/pages/, domain in flat src/ tree (constants, i18n, toolkits).

src/
  lib.rs                          # i18n init, tr! macro, run(), vault migration at startup
  assets.rs                       # App AssetSource (extends gpui-component icons)
  main.rs
  router/mod.rs                   # Thin ActiveScreen → page dispatch
  layouts/default/                # App shell (DefaultLayout: action + view)
  components/
    pages/                        # Full screens
      notes/                      # Notes list (search, date filter, pagination)
      daily_note/                 # Single-day editor + preview + copy/export
      settings/                   # Theme + locale
      about/                      # App info + privacy policy
    atoms/                        # page_title
    molecules/
      sidebar_nav_item/           # action + view + style
      note_calendar/              # action + view + style
      markdown_toolbar/           # action + view + style
    organisms/
      app_sidebar/                # action + view
      app_titlebar/               # action + view
  constant/                       # Domain enums
    app/preference_path/
    nav/item_id/  active_screen/
    locale/code/
    note/date_key/  summary/
    layout/spacing/
    typography/family/  size/
  i18n/                           # I18n{Page} enums
    about/  app/  calendar/  daily_note/  nav/  notes/  settings/
  style/
    tokens.rs                     # Theme token application
    material.rs                   # Sidebar/titlebar/content materials, dialog surfaces
    icon.rs                       # Shared icon sizing + custom icon paths
  toolkit/                        # Cross-feature adapters
    locale/  theme/  daily_note/  note_vault/  notes_export/
    markdown_editor/  preview_http/  date_format/  font/

assets/
  icons/                          # App-local SVG icons (merged via src/assets.rs)

content/
  locales/app.yml                 # Translation copy (en, id)

scripts/  packaging/  Makefile

Navigation model

Concept Location Role
NavItemId src/constant/nav/item_id/mod.rs Sidebar item IDs: Daily, Notes, Settings, About
NAV_ITEMS same module Sidebar render order
ActiveScreen src/constant/nav/active_screen/mod.rs Nav(NavItemId) or DailyNote(NoteDateKey)
DefaultLayout src/layouts/default/action.rs Shell state: active screen, calendar, notes table, inputs

Default screen: ActiveScreen::Nav(NavItemId::Notes)

Daily nav item: opens today's daily note (open_today_daily_note), not a standalone list page.

Router dispatch: src/router/mod.rs matches layout.active_screen() → page render(). No business logic.

Page architecture

Every screen lives under src/components/pages/{name}/:

src/components/pages/{name}/
  mod.rs
  view.rs       # Markup only — calls action getters/handlers
  action.rs     # Getters, event handlers, enum dispatch
  style.rs      # Layout constants (max width, spacing, page size)

Rules:

  • Router only dispatches; no page logic in router/mod.rs.
  • Views do not contain handler logic or inline i18n key strings — use action.rs and i18n/ enums.
  • Layout state (DefaultLayout) lives in layouts/default/action.rs, not in page modules.
  • Only import component view modules (or public render() helpers) from parents. Do not treat action.rs as a render entry point.
  • Pages import toolkits and constants; toolkits must not import page modules.

action.rs section order

1. DEFAULT      — imports, private enums used by handlers
2. GETTER STATE — labels, flags, resolvers via match on enums
3. HANDLER      — named event functions (on_*)
4. PRIVATE      — helpers extracted from handlers (same file, below handlers)

Applies to all action.rs files: pages, layouts, molecules, organisms.

action.rs control-flow rules

These rules apply to every action.rs in the repo (components/pages/, layouts/, components/).

Allowed patterns

Pattern Use for
Early return on negative guard if !condition { return; }
Sequential single-level if Independent branches with early return each
match on enums / structured destructuring NavItemId, ActiveScreen, ExportFormat, TableEvent
let ... else { return ... } Option/Result guard without nesting
One loop per function for, while, or iterator .filter().map() — not nested
Extract helper fn Logic that would nest inside a loop or if

Forbidden patterns

Pattern Fix
if/else or if ... else if chains Early return per branch, or match on enum
Nested if (if inside if) Flatten with early return; split into helper fn
if inside while/for body Move body to helper fn; loop only calls helper
Nested loops Single loop + helper, or iterator pipeline
Nested functions Private fns at module level in same file
Blank lines inside function bodies See Spacing & vertical rhythm
Ternary-style if a { x } else { y } in expression position Use .min()/.max(), match, or helper fn

Refactor examples

Nested if → early return:

// Bad
if is_active {
    if cx.theme().mode == ThemeMode::Dark {
        return cx.theme().sidebar_primary;
    }
    return cx.theme().sidebar_accent_foreground;
}
cx.theme().sidebar_foreground

// Good
if !is_active {
    return cx.theme().sidebar_foreground;
}
if cx.theme().mode == ThemeMode::Dark {
    return cx.theme().sidebar_primary;
}
cx.theme().sidebar_accent_foreground

if/else bounds → Ord methods:

// Bad
let min_date = if start <= end { start } else { end };

// Good
let min_date: NoteDateKey = start.min(end);
let max_date: NoteDateKey = start.max(end);

if inside while → helper:

// Bad — if inside while
while cell_index < 35 {
    week_days.push(date);
    if week_days.len() == 7 {
        weeks.push(week_days);
        week_days = Vec::new();
    }
}

// Good — loop calls helper with no conditionals
while cell_index < 35 {
    push_calendar_day(&mut weeks, &mut week_days, date);
    cell_index = cell_index.saturating_add(1);
}

UI components (atomic design)

Layer Path pattern action.rs
Atoms src/components/atoms/{name}/view.rs Rare
Molecules src/components/molecules/{name}/ When state/logic needed
Organisms src/components/organisms/{name}/ When nav/state needed

Molecules with logic use the same split as pages:

src/components/molecules/{name}/
  mod.rs
  view.rs
  action.rs
  style.rs      # optional layout constants

Current molecules with action.rs: sidebar_nav_item, note_calendar.

Dependencies flow downward: pages → organisms → molecules → atoms → style tokens.

Domain constants

  • Enums in src/constant/{domain}/{name}/mod.rs — PascalCase members, one concept per folder.
  • NavItemId for navigation IDs; LocaleCode for locale codes; NoteDateKey for note dates.
  • Never duplicate enum values as string literals in views.

Internationalization (i18n)

Part Location
Key registry src/i18n/{page}/mod.rs
Copy content/locales/app.yml
Runtime LocaleToolkit + I18n*::translate()

Rules:

  • crate::tr!("literal.key") only inside i18n registry modules (macro requires literal keys).
  • Views call I18nNotes::Export.translate() or action getters — not raw keys.
  • Locale preference persists to {app_data}/locale via LocaleToolkit::apply_and_persist().
  • Startup locale: LocaleToolkit::resolve_startup_locale() (saved file → default en).
  • Privacy policy copy lives in about.privacy_body (content/locales/app.yml); keep EN/ID in sync when storage or encryption behavior changes.

Encrypted storage

Part Location
App data root preference_path::preference_dir()~/.daynote (Unix) or %USERPROFILE%\.daynote (Windows)
Note files {app_data}/notes/{YYYY-MM-DD}.md (encrypted binary on disk, .md extension kept)
Images {app_data}/images/{YYYY-MM-DD}/ (encrypted binary on disk)
Vault API src/toolkit/note_vault/index.rsNoteVaultToolkit::read_*, write_*, migrate_storage()
Startup migration NoteVaultToolkit::migrate_storage() in src/lib.rs after init

Rules:

  • All note/image disk I/O goes through NoteVaultToolkit (via DailyNoteToolkit / NoteImageToolkit). Do not read_to_string or write note/image paths directly.
  • File format: magic DNT1 + nonce + AES-256-GCM ciphertext; AAD is the path relative to app data root.
  • Master key: keyring with platform features (apple-native, windows-native, sync-secret-service, crypto-rust). Do not store the key in the app data folder.
  • Export and preview read decrypted bytes in memory only (NotesExportToolkit, PreviewHttpClient, DailyNoteToolkit::load).
  • Legacy plaintext files are re-encrypted on read or during startup migration.
  • On Linux, Secret Service (GNOME Keyring / KWallet) must be available for key persistence; without it, save may fail rather than fall back to plaintext.

Toolkits

Each toolkit exports one PascalCase struct from src/toolkit/{name}/index.rs:

Toolkit Responsibility
LocaleToolkit resolve_startup_locale(), load_saved_locale(), save_locale(), apply(), apply_and_persist()
ThemeToolkit sync_system(), is_dark(), set_light(), set_dark()
NoteVaultToolkit AES-256-GCM encrypt/decrypt, keychain access, legacy migration
DailyNoteToolkit Load/save note content (via vault), list dates and summaries
NoteImageToolkit Import/paste/cleanup images (via vault), preview URI rewrite
NotesExportToolkit Export single or all notes as decrypted Markdown/TXT in ZIP
PreviewHttpClient Serves local decrypted images to the markdown preview
DateFormatToolkit Localized month/weekday/daily-note titles

Features import toolkits; toolkits must not import feature modules.

Spacing & vertical rhythm (Rust)

Enforced by scripts/format-rust-guard-spacing.py (fix: run without --check).

File level — 1 blank line

Between Blank lines
Import groups (std → external crate → crate::) 1
Imports and first top-level item 1
Top-level fn, struct, enum, const, impl blocks 1
impl methods 1
action.rs sections (getter → handler → private helper) 1

Guard returns — blank line between guards

A guard is an if without else whose then-branch ends in return, or let … else { return / continue / break }.

Rule Blank lines
Between consecutive guards (block or one-line) 1
Inside a guard block (statements before return) 0
Before return inside a guard block 0, unless the prior statement spans multiple lines
After the last guard 1 only when two or more non-guard statements follow
After the last guard before a single fallthrough statement 0
Between regular let bindings (non-guard) 0
Between non-guard if blocks (side effects, no early return) 0
pub fn label_color(is_active: bool, cx: &App) -> Hsla {
    if !is_active {
        return cx.theme().sidebar_foreground;
    }

    if cx.theme().mode == ThemeMode::Dark {
        return cx.theme().sidebar_primary;
    }
    cx.theme().sidebar_accent_foreground
}
pub fn on_click(item: NavItemId, shell: Entity<DefaultLayout>,) {
    if item == NavItemId::Daily {
        shell.update(cx, |layout, cx| {
            layout.open_today_daily_note(window, cx);
        });
        return;
    }
    shell.update(cx, |layout, cx| {
        layout.select_nav_item(item, cx);
    });
}

Loop spacing — blank line before and after

Rule Blank lines
Before while / for (after setup lets) 1
After while / for (before trailing statements) 1
Inside loop body 0
fn strip_markdown_links(text: &str) -> String {
    let mut result: String = String::new();
    let mut rest: &str = text;
    let mut keep_scanning: bool = true;

    while keep_scanning {
        keep_scanning = process_next_markdown_link(&mut result, &mut rest);
    }

    result.push_str(rest);
    result
}

let … else guard spacing

Consecutive let … else { return … } guards follow the same blank-line rules as if guards.

fn process_next_markdown_link(result: &mut String, rest: &mut &str) -> bool {
    let Some(start) = rest.find('[') else {
        return false;
    };

    result.push_str(&rest[..start]);
    let Some(label_end) = after_bracket.find(']') else {
        result.push('[');
        result.push_str(after_bracket);
        *rest = "";
        return false;
    };
    // …
}

action.rs section spacing

One blank line when switching getters → handlers → private helpers. No blank line between getters in the same group.

Coding rules (Rust)

  • Explicit types on parameters, return values, and local variables where non-obvious.
  • Early return on negative conditions; prefer match for enum branches over if/else chains.
  • No nested functions, nested loops, or nested conditionals beyond one level inside a function.
  • Follow Spacing & vertical rhythm; run scripts/format-rust-guard-spacing.py after editing guard or loop layout.
  • No unused exports, handlers, modules, or variables.
  • No new external dependencies without user approval (approved vault deps: aes-gcm, keyring, rand).
  • Minimize diff scope — match existing patterns in the nearest feature/module.
  • Do not DRY unrelated code unless asked.
  • No console.log equivalent — use proper error handling or silent discard where existing code does.

Styling

GPUI uses programmatic styling (.px_8(), .gap_4()), not CSS modules.

Tier Location
Tokens src/style/tokens.rs
Materials src/style/material.rs
Icon sizing src/style/icon.rs
Feature layout src/components/pages/{name}/style.rs
Component layout src/components/molecules/{name}/style.rs

Use cx.theme() / ActiveTheme for colors — avoid hardcoded HSLA in features. Light-mode sidebar uses macOS-native tokens from material.rs.

Adding a new screen

  1. Add variant to NavItemId in src/constant/nav/item_id/mod.rs and NAV_ITEMS.
  2. Create src/components/pages/{name}/ with view.rs, action.rs, style.rs.
  3. Add I18n{Name} enum in src/i18n/{name}/mod.rs and register in src/i18n/mod.rs.
  4. Add keys to content/locales/app.yml (both en and id).
  5. Wire dispatch in src/router/mod.rs and register module in src/components/pages/mod.rs.
  6. Sidebar picks up new item automatically via NAV_ITEMS.
  7. If screen needs shell state, extend DefaultLayout in layouts/default/action.rs.

What not to do

  • Put page logic in router/mod.rs.
  • Register or import action.rs as a UI render module from unrelated parents.
  • Duplicate domain enums or i18n keys across features.
  • Use magic strings for nav items, locales, or storage paths in views.
  • Read or write note/image files on disk outside NoteVaultToolkit / its toolkit wrappers.
  • Add dependencies without approval (crypto crates aes-gcm, keyring, rand are approved for vault storage).
  • Use nested loops, nested if, or if/else in any action.rs.
  • Call shell.update from view.rs, *_view.rs, router/mod.rs, or layouts/default/view.rs (use &DefaultLayout getters, prepare_* on &mut self, or window.defer instead).
  • Nest shell.update calls on the same entity in one callback.
  • Create markdown files unless explicitly requested.

Related documentation

Document Purpose
ARCHITECTURE.md Layer model and porting guide
README.md Quick start