# NoteDeck Development Guide Misskey IDE (Integrated Deck Environment) with fork support — branded as "Misskey Pro" for power users ([BRANDING.md](BRANDING.md))。設計思想・方針は [DESIGN.md](DESIGN.md) を参照。 ## Tech Stack | | | |---|---| | Frontend | Vue 3 + TypeScript(Vapor モード移行予定) | | Backend | Rust (Tauri v2) + [notecli](https://github.com/notedeck-dev/notecli) | | Build | Vite 8 (Rolldown) + Cargo | | State | Pinia | | Local DB | SQLite (rusqlite, WAL mode, FTS5) | | HTTP | reqwest (Rust, via notecli) | | WebSocket | tokio-tungstenite (Rust, via notecli) | | HTTP API | Axum (localhost:19820) | | Script | AiScript (@syuilo/aiscript) | | Editor | CodeMirror 6 | | Linter | Biome | | Style | SCSS + CSS Modules (`$style`) | | Test | Vitest + happy-dom | ## Prerequisites ### 推奨: Nix flake(ワンコマンドセットアップ) [Nix](https://nixos.org/) がインストール済みなら、全ての開発依存が自動で揃います。 ```bash nix develop # Node.js, pnpm, Rust 等が揃ったシェルに入る pnpm install # パッケージインストール ``` [direnv](https://direnv.net/) を使うと `cd` するだけで自動的に環境が有効になります(`.envrc` 同梱)。 Android SDK / NDK は nix store を数 GB 占めるため、既定のシェルには入っていません。Android をビルドするときだけ専用シェルに入ってください: ```bash nix develop .#android # 上記に加えて JDK + Android SDK/NDK が揃う(内訳は flake.nix) ``` ### 手動セットアップ | ツール | インストール | |--------|-------------| | [Node.js](https://nodejs.org/) (LTS) | 公式サイト or `nvm install --lts` | | [pnpm](https://pnpm.io/) | `corepack enable && corepack prepare pnpm@latest --activate` | | [Rust](https://www.rust-lang.org/) (stable) | `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh` | **Linux のみ**: Tauri のビルドに追加パッケージが必要です。 ```bash # Ubuntu / Debian sudo apt install libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf ``` ### 環境診断 セットアップしたのに動かないときは、原因を自力特定せず診断コマンドを実行してください: ```bash pnpm doctor ``` 必要なツールチェーン・システム依存・モバイル SDK の有無を検査し、欠落ごとに具体的な対処コマンドを提示します([#896](https://github.com/notedeck-dev/notedeck/issues/896))。 ### エディタ / 言語サーバー 言語サーバーは開発環境に同梱されており、`nix develop` に入った時点でエディタを問わず補完・定義ジャンプが動きます: | 対象 | 言語サーバー | 導入経路 | |------|-------------|---------| | Rust | rust-analyzer + rust-src | `rust-toolchain.toml` の components | | TOML | taplo | `flake.nix` | | Nix | nil | `flake.nix` | | Vue SFC | vue-language-server | `flake.nix` | | TypeScript | tsserver | `node_modules` の typescript(ワークスペース版) | VS Code 向けの表示層は `.vscode/` に同梱(推奨拡張・ワークスペース設定・デバッグ構成): - リポジトリ直下に Cargo マニフェストが無いため、`rust-analyzer.linkedProjects` で `src-tauri/Cargo.toml` を明示している。他エディタでも同等の設定が必要 - デバッグ構成「Tauri desktop (debug)」で Rust 側にブレークポイントを張れる (CodeLLDB 使用。vite dev server は preLaunchTask で自動起動) - WSL2 では `nix develop` したシェルから VS Code を起動すること(EGL 対策の環境変数を継承するため) #### IPC 境界を跨ぐジャンプ [#897](https://github.com/notedeck-dev/notedeck/issues/897) — 言語サーバーはフロントと Rust の境界を越えられないため、`commands.xxx()` から実装へは飛べません。 生成物である `src/bindings.ts` の各コマンドに、実装ファイルへの `@see` が埋め込んであります(生成のたびに実測から作り直されるので腐りません)。 | 知りたいこと | 手順 | |---|---| | このコマンドの実装はどこか | `commands.xxx()` の定義へ飛び、JSDoc の `@see` のパスを開く | | この Rust 実装を誰が呼んでいるか | `bindings.ts` を関数名(snake_case のまま)で検索して TS 側の名前を得る → その名前で `src/` を検索 | 逆方向が 2 手になるのは、呼び出し元の情報を手書きの Rust ソースへ書き戻すことになり、生成物ではなくなるためです。 どちらの手順も snake_case と camelCase の変換を人間がやる必要はありません。 ## Getting Started ```bash # Install dependencies pnpm install # Start dev server (Tauri desktop) pnpm tauri:dev # Start dev server (browser) — tauri:dev 起動中に開くと Dev Dashboard になる(下記参照) pnpm dev ``` ## Available Scripts ```bash pnpm dev # Vite dev server pnpm tauri:dev # Tauri dev pnpm build # Production build pnpm tauri:build # Tauri native build pnpm test # Run unit tests pnpm test:watch # Run tests in watch mode pnpm test:e2e # Run E2E tests (要デバッグビルド、下記参照) pnpm lint # Lint & format check pnpm lint:fix # Lint & format fix pnpm typecheck # TypeScript type check pnpm clean # Remove build artifacts ``` ### テスト構成 テストは 2 プロジェクトに分離(`vitest.config.ts`): | プロジェクト | 環境 | ファイルパターン | 用途 | |------------|------|----------------|------| | `unit` | Node.js | `*.test.ts` | ロジック・ユーティリティ | | `dom` | happy-dom | `*.dom.test.ts` | Vue コンポーネント・DOM 操作 | ### E2E テスト([#702](https://github.com/notedeck-dev/notedeck/issues/702)) 実アプリ(デバッグビルド)を隔離プロファイルで起動し、外部アプリと同じ HTTP API 面([#709](https://github.com/notedeck-dev/notedeck/issues/709)、port 19820)で駆動する。設定は `vitest.e2e.config.ts` (`pnpm test` とは独立、`tests/e2e/` 配下)。 ```bash cd src-tauri && cargo build && cd .. # デバッグバイナリを用意 (初回のみ) nix develop -c pnpm test:e2e # WSL2 では nix develop 必須 (EGL 対策) ``` - ハーネス(`tests/e2e/harness.ts`)は一時ディレクトリを `NOTEDECK_APP_DIR` に指定してバイナリを spawn し、実データに触れない。バイナリの場所は `NOTEDECK_E2E_BINARY` で上書き可能 - port 19820 が使用中(= 実アプリ起動中)の場合は誤操作防止のため即失敗する - デバッグビルドは devUrl(vite 5173)から frontend を読むため、vite が いなければハーネスが自前で起動・終了する - アサーションは HTTP の state 読み取り(`/api/health` / `/api/deck/columns` 等)ベース。DOM/ピクセルには依存しない - モック Misskey サーバー(`tests/e2e/mockMisskey.ts`)でストリーミングの 切断/再接続/購読 replay を決定論的にテストする。接続には `NOTECLI_INSECURE_HOSTS` / `NOTEDECK_E2E_ALLOW_HOSTS`(デバッグビルド限定の http/ws 許可)を使う - CI では `xvfb-run` で実行する。視覚スモーク(`NOTEDECK_E2E_SCREENSHOT=1` でスクリーンショットをアーティファクト保存)は develop / main への push の ときだけ有効にしている。GPU なしランナーではソフトウェアレンダリングを 強制するため実機の描画崩れは再現できず、検証も「単色ではない」ことに 留まる(崩れの判断はアーティファクトを見る人間に委ねている)。実接続を 伴い所要時間も大きいので、PR では回さない #### Android 実機 / エミュレータで同一スイートを実行 アプリをデバイス上で起動した状態で、HTTP API を adb 経由でホストに引き込み、 attach モードで同じテストを流す(モック接続系テストは自動スキップ): ```bash adb forward tcp:19820 tcp:19820 # デバイスの api-token を取得 (デバッグビルドは run-as が使える。 # app_data_dir 配下の api-token — パスは要確認) TOKEN=$(adb shell run-as com.notedeck.desktop cat files/api-token) NOTEDECK_E2E_ATTACH=1 NOTEDECK_E2E_TOKEN=$TOKEN pnpm test:e2e ``` ※ この手順は未実機検証。 attach モードはデバイス側アプリを終了させず、デッキ操作系テストは実際に カラムを追加/削除する(テスト用プロファイルのデバイスで実行すること)。 ## Dev Dashboard([#977](https://github.com/notedeck-dev/notedeck/issues/977)) `pnpm tauri:dev` 中にブラウザで http://localhost:5173/ を開くと、実行中の アプリを外から覗く **開発者ダッシュボード** になる(アプリ未起動時は起動案内が 出て、起動を検知すると自動で切り替わる)。デッキとは独立した画面なので、 アプリの UI 状態を汚さずに観測できる。 できること: - **デッキ状態** — カラム一覧と、折りたたみで `/api/health` raw・ `/api/openapi.json`・起動計測(起動マーク + WebView 固定費、[#985](https://github.com/notedeck-dev/notedeck/issues/985))・ HEARTBEAT 状態(最終 tick / 結末 / 連続失敗、[#411](https://github.com/notedeck-dev/notedeck/issues/411))・キャッシュ観測 (上限つきキャッシュの size / limit 実測、[#987](https://github.com/notedeck-dev/notedeck/issues/987))・Query Bridge トレース (query 往復の所要時間)。JSON はシンタックスハイライト付き - **SSE ライブビューア** — `/api/events` を購読して Rust 側イベントバスの 流れをリアルタイム表示。type prefix フィルタ・種別チップ(クリックで 絞り込み)・流量表示・JSON Lines エクスポート・切断時自動再接続。 折りたたみで Inspector 突き合わせ(アダプタ層 vs SSE の種別別カウント) - **Capabilities 実行盤** — 登録済み capability の一覧からパラメータを JSON で組んで実行。external principal として dispatcher を通るので、 権限ゲート([#712](https://github.com/notedeck-dev/notedeck/issues/712))の deny・確認ダイアログ・DispatchResult → HTTP status の 写像をそのまま目視テストできる。principal 別実効権限マトリクスと 実行履歴つき - **統合タイムライン** — Rust の tracing ログ(Vite dev server の `/dev/logs` が SSE 配信、所在は `/api` インデックスの `logDir` から解決) + SSE イベント + フロント in-app ログを単一時系列にマージ。 「どの層でイベントが消えたか」をソース切替しながら 1 画面で追える - **Scalar API ドキュメント** — `/api/docs` へのリンク 仕組み: Vite の dev proxy(`vite.config.ts`)が `/api` と `/proxy` を 内蔵 HTTP サーバー(127.0.0.1:19820、[#940](https://github.com/notedeck-dev/notedeck/issues/940))へ転送し、無認証の `/api` インデックスが開示する tokenPath から Bearer トークンを読んで注入する。 ブラウザ側は相対パスの fetch だけで認証込みの external API を叩ける。 dev マシン上でしか成立しない橋渡しなので、本番の攻撃面は増えない。 Stream Inspector カラムとの違い: Stream Inspector は**フロントのアダプタ層** (Misskey WebSocket の raw イベント)を見るのに対し、ダッシュボードの SSE ビューアは **Rust 側イベントバス → `/api/events`** を見る。別系統なので、 両方を並べると「どの層までイベントが届いているか」の切り分けに使える。 位置づけ(#940 との関係): このダッシュボードは external API の最初の 本格クライアント(dogfooding)を兼ねる。19820 に新しい面を足すときの テストベンチとして育てる。 ## Architecture NoteDeck は **notecli** と **notedeck** の 2 リポジトリで構成されています。 ### notecli ([github.com/notedeck-dev/notecli](https://github.com/notedeck-dev/notecli)) Tauri に依存しない Misskey ヘッドレスクライアント。Rust ライブラリ兼 CLI デーモン。 - Misskey HTTP API クライアント、WebSocket ストリーミング、SQLite DB、REST API サーバー - 単体で `localhost:19820` の HTTP API デーモンとして動作(GUI 不要) - NoteDeck の Rust バックエンドとして `Cargo.toml` の git 依存で利用される ### notedeck (このリポジトリ) notecli の上に Tauri v2 + Vue 3 の GUI を載せたクライアント。 対象プラットフォームは Windows / macOS / Linux / Android。 ``` src/ # Vue 3 frontend ├── adapters/ # Server API adapters (Misskey, forks) │ ├── types.ts # Shared interfaces (ServerAdapter, ApiAdapter, StreamAdapter) │ ├── registry.ts # Adapter factory │ └── misskey/ # Misskey implementation (IPC via invoke/listen) ├── aiscript/ # AiScript runtime & Misskey Play API ├── commands/ # Command registry, definitions, CLI handlers ├── components/ # Vue components │ ├── common/ # MkNote, MkPostForm, MkEmoji, CommandPalette, etc. │ └── deck/ # DeckLayout, DeckColumn, column types ├── composables/ # Vue composables (useNoteFocus, useTimeMachine, etc.) ├── core/ # Business logic (server detection) │ ├── queryDeltaBus.ts # query-delta の単一リスナー多重化・復帰時再アタッチ │ ├── queryRegistry.ts # プラグイン fan-out 用の queryId refcount レジストリ │ └── streamHealth.ts # 生のストリーム接続状態台帳 (#698) ├── data/ # Static data & constants ├── router/ # Vue Router definitions ├── stores/ # Pinia stores (accounts, deck, servers, emojis, theme, etc.) ├── styles/ # Global CSS (CSS variables) ├── theme/ # Misskey-compatible theme compiler & applier ├── utils/ # Shared utilities └── views/ # Page components (NoteDetail, UserProfile) src-tauri/src/ # Rust backend (Tauri 固有部分) ├── lib.rs # App setup (tray, plugins, state) ├── commands/ # Tauri IPC command handlers (notecli 呼び出し) │ ├── mod.rs # 共通ユーティリティ (validate_host, get_credentials 等) │ ├── timeline.rs # タイムライン系コマンド │ ├── content.rs # ノート操作系コマンド │ ├── user.rs # ユーザー系コマンド │ ├── messaging.rs # チャット・DM 系コマンド │ ├── streaming.rs # ストリーミング系コマンド │ ├── settings.rs # 設定系コマンド │ ├── admin.rs # 管理系コマンド │ ├── auth.rs # 認証系コマンド │ ├── enrichment.rs # OGP・エンリッチメント系コマンド │ └── utility.rs # ユーティリティ系コマンド ├── http_server.rs # Axum HTTP API server (localhost:19820) ├── permissions_gate.rs # external principal gate (#712) — 永続トークンの per-route 権限判定 ├── image_cache.rs # 3-tier image cache (memory → disk → network) ├── ogp/ # OGP metadata extraction & cache ├── streaming.rs # TauriEmitter adapter (FrontendEmitter trait impl) ├── query_bridge.rs # HTTP API ↔ frontend (Pinia) bridge ├── perf_config.rs # パフォーマンス設定 (Rust 側) └── main.rs # Entry point ``` Misskey API クライアント・DB・モデル・ストリーミングコアなどの共通ロジックは全て `notecli` クレートにあり、`src-tauri/` には Tauri 固有の薄いラッパーのみ残っています。 ### Boot Sequence NoteDeck は **UI 表示までの時間を最小化** するため、バックエンドの重い初期化をバックグラウンドで行いながらフロントエンドを先行起動する設計になっている。 #### 全体フロー Rust バックエンドとフロントエンドが並列に動作し、イベントで連携する。 ```mermaid sequenceDiagram participant R as Rust (Tauri) participant W as WebView participant V as Vue (Frontend) Note over R: Phase 1 — 軽量初期化 (< 50ms) R->>R: Tokio ランタイム作成 (workers=4) R->>R: Tauri プラグイン登録 R->>R: setup(): データディレクトリ / キーチェーン / マイグレーション R->>R: AppState を empty で登録 R->>R: PerfConfig / HTTP Client / EventBus 初期化 R->>W: WebView ロード開始 Note over W: スプラッシュ画面表示 (#nd-splash) par Phase 2 — バックグラウンド重初期化 R->>R: [Thread 1] DB open R->>R: [Thread 2] MisskeyClient init R->>R: [Thread 3] HTTP server bind end W->>V: main.ts 実行開始 V->>V: initEarlyAccountListener() (nd:accounts-early を同期登録) V->>V: Tauri API / DeckPage 事前フェッチ V->>V: createApp → Pinia → Router V->>V: themeStore.init() (localStorage 復元) V->>V: keybinds / performance init V->>V: accountsStore.loadAccounts() (fire-and-forget) V->>V: app.mount('#app') V->>V: App.vue: window.show() Note over R: Stage 1 — DB 準備完了 R->>R: DB マイグレーション R->>R: app_state.initialize_db(db) par アカウント取り込み(どちらか先着で populate) R-->>V: nd:accounts-early イベント → 事前登録 listener が即時 apply V->>R: invoke('load_accounts') (safety net) R-->>V: IPC レスポンス → 未 populate なら apply end R->>R: StreamingManager 初期化 Note over R: Stage 2 — 完全準備完了 R->>R: app_state.initialize(db, client) R->>R: HTTP サーバー起動 + OGP/画像キャッシュ R-->>V: nd:backend-ready イベント V->>V: DeckLayout mount V-->>V: nd:deck-mounted → スプラッシュ解除 V->>V: deckStore.startSync() (ストリーミング接続) V->>V: registerDefaultCommands() Note over V: requestAnimationFrame (defer) V->>V: ApiBridge / Notifications / OGP / CLI commands Note over V: requestIdleCallback (defer) V->>V: KaTeX CSS / Shiki CSS ``` #### Two-stage AppState バックエンドの初期化を 2 段階に分けて、DB が準備できた時点で一部のコマンドを先行アンロックする仕組み(`src-tauri/src/commands/mod.rs`)。 ```mermaid stateDiagram-v2 [*] --> Empty : AppState new() Empty --> DB_Ready : initialize_db(db) DB_Ready --> Fully_Ready : initialize(db, client) Empty : 全コマンド待機 DB_Ready : DB専用コマンドがアンロック DB_Ready : (load_accounts 等) Fully_Ready : 全コマンドがアンロック ``` 内部的には `tokio::sync::watch::channel` を 2 本持ち、`db()` は DB チャネルのみ、`client()` はフルチャネルを待機する。これにより `load_accounts` のようなDB専用コマンドは MisskeyClient の初期化を待たずに応答できる。 #### スプラッシュ画面 `index.html` にインラインで定義された `#nd-splash`(ハートビートアニメーション付きロゴ)。 - **表示**: WebView ロード直後(Vue マウント前から表示済み) - **解除**: `uiStore.deckMounted`(DeckLayout のマウント完了)を `App.vue` が watch して解除 - **フォールバック**: タイムアウトで強制解除(時間は `App.vue` の splashTimeout が正本) - **アニメーション**: `opacity: 0` トランジション → `transitionend` で DOM 削除 - **計測**: 起動フェーズは `src/utils/startupTrace.ts` の performance.mark で記録し、About ウィンドウの「起動パフォーマンス」に表示(#985) データ(ノート等)のロード完了は待たず、カラムフレームの描画が完了した時点でスプラッシュを解除する。 #### 起動時の最適化テクニック | テクニック | 実装箇所 | 効果 | |-----------|---------|------| | Two-stage AppState | `commands/mod.rs` | DB 準備次第でアカウント読み込み開始 | | 早期アカウントイベント | `lib.rs` → `nd:accounts-early` | IPC 往復を待たずにフロントへ通知 | | 事前登録 listener | `stores/accounts.ts:initEarlyAccountListener` | `main.ts` 最上部で `listen()` を同期登録し、Rust 側 emit を取りこぼさない(Pinia 初期化前でも module-scope バッファに保存) | | 未ロード時の空状態ガード | `DeckColumn.vue` `requireAccount` prop | カラム本体スロットを `accountsStore.isLoaded` まで抑制し「アカウントが見つかりません」の一瞬のチラつきを防止 | | 動的 import 事前フェッチ | `main.ts` | DeckPage / カラムチャンクを並列ダウンロード | | テーマ localStorage 復元 | `themeStore.init()` | ネットワーク不要で FOUC 防止 | | 3 スレッド並列初期化 | `lib.rs` Phase 2 | DB / Client / HTTP bind を同時実行 | | 非クリティカル処理の defer | `useDeckInit` | rAF / rIC で初回描画を優先 | ### Multi-Window & Profile Architecture ウィンドウとプロファイルは**直交する概念**です。 - **プロファイル**: カラム構成・レイアウトの保存単位。データの所有者 - **ウィンドウ**: プロファイルの表示先。同じプロファイルを複数ウィンドウで開ける ``` Profile A ──→ Main Window(windowId なしのカラムを表示) └──→ Sub Window 1(windowId = "w1" のカラムを表示) Profile B ──→ Main Window(プロファイル切り替え時) ``` **設計原則:** 1. プロファイルが変更されたら、そのプロファイルを開いている**全ウィンドウ**がリアクティブに追従する 2. 各ウィンドウは `windowLayout`(computed)で自分に属するカラムだけをフィルタして表示する 3. ウィンドウの作成・破棄はプロファイルのデータに影響しない **同期方式:** localStorage(全 webview 共有)を SSoT とし、Tauri イベント(`deck:profile-updated`)でキャッシュ無効化を通知。Rust 側に SSoT を移す案も検討したが、localStorage が既に全 webview で共有されており、本質的に同じ構造になるため不採用([PR #172](https://github.com/notedeck-dev/notedeck/pull/172) で議論)。 ### Window / Column Model([#194](https://github.com/notedeck-dev/notedeck/issues/194)) ウィンドウとカラムは「ストリーム / 詳細 / ツール」の3分類で役割を分担する。 | 分類 | UI | 用途 | 永続性 | |------|-----|------|--------| | **ストリーム** | カラム | 継続的なデータフィード(TL、通知、検索、チャット等) | プロファイルに永続化 | | **IDE ツール** | カラム | 開発・デバッグ支援(Stream Inspector、Workspace Explorer 等)、ローカル PKM(メモ — `settings/memos/*.md` を Obsidian vault としても開ける。サーバーへ送らずローカルで完結し、同じくアカウントなしの AI カラムから参照されるため **アカウントに紐づかない**(#1018)。他のテキストと同じ編集履歴を持つ) | プロファイルに永続化 | | **詳細** | ウィンドウ | 特定アイテムの一時的な表示(ノート詳細、プロフィール、フォローリスト) | セッション限り | | **インスペクタ** | ウィンドウ | Raw JSON 表示・デバッグ(ノート/通知インスペクタ、settings.json エディタ) | セッション限り | | **ツール** | ウィンドウ | アプリ設定・管理(ログイン、エディタ群、プラグイン、about) | セッション限り | **Cross-account カラム:** カラムのアカウントスコープは 3 状態ある。`accountId` は `string | null` のままで、`null` の意味は registry の `crossAccount` 宣言から引き直す(#1018)。 | スコープ | 条件 | 動作 | |---------|------|------| | **per-account** | `accountId` あり | `useColumnSetup` で単一アダプタ | | **全アカウント** | `accountId: null` + `crossAccount: true` | `useMultiAccountAdapters` で全アカウント並列取得 | | **アカウントなし** | `accountId: null` + `crossAccount` 宣言なし | アカウントに紐づかない(AI・スキル・タスク等) | 判定は `src/columns/accountScope.ts` の `getAccountScope()` 一本。カラムを受け取る側が「束ねるべき」か「関係ない」かを各自で判定すると、対応種別が増えるたびに虫食いが再発するため、この 1 箇所を経由する。対応種別の正本は `src/columns/registry.ts` の `crossAccount` 宣言。 全アカウントのカラムはヘッダーに `AvatarStack` が出る(アカウントなしは何も出ない)。そこからアカウント必須の操作を始めるときは `useAccountPicker` でどのアカウントで実行するかを選ばせる — アクティブアカウントへ暗黙にフォールバックしない。 **ナビバー(VSCode Activity Bar 式):** 左ナビバーのアイコンはカラムの**トグルボタン**として機能する。クリックでサイドバーカラムを左端(`layout[0]`)に挿入、再クリックで削除。同時に1スロットのみ(`sidebar: true` フラグで管理)。 ナビバーのボタン構成はカスタマイズ可能(設定 → ナビバー)。`NavItem = { type, accountId } | { type: 'divider' }` 構造体でプロファイルに永続化される。 **共通コンポーネント:** | コンポーネント | 用途 | 使用箇所 | |-------------|------|---------| | `ColumnBadges` | サーバー/アカウントバッジ表示 | DeckNavbar, DeckBottomBar, DeckMobileNav | | `AvatarStack` | cross-account 時のアカウントアバター重ね表示 | AddColumnDialog, カラムヘッダー | | `EditorTabs` | ビジュアル/コード 2タブ切替(コードタブはデフォルト値との差分のみ表示) | 全エディタ系ウィンドウ共通 | | `RawJsonView` | Raw JSON 表示(機密マスキング・コピー対応) | NoteInspectorContent, NotificationInspectorContent, UserProfileContent | **共通 composable:** | composable | 用途 | 使用箇所 | |-----------|------|---------| | `usePointerReorder` | Pointer イベントによるドラッグ&ドロップ並び替え(軸指定対応) | NavEditorContent, ProfileEditorContent | | `useCrossAccountNotes` | 複数アカウントからのノート並列取得・統合・重複排除 | DeckMentionsColumn, DeckSpecifiedColumn | | `useVerticalResize` | 上下分割ペインのドラッグリサイズ(高さ制限付き) | DeckStreamInspectorColumn, DeckAiScriptColumn | | `useSensitiveMask` | 機密フィールドのマスキング表示・トグル reveal | NoteInspectorContent, NotificationInspectorContent, UserProfileContent | **アイコン・ラベルの一元定義:** `useColumnTabs.ts` の `COLUMN_ICONS` / `COLUMN_LABELS` がカラムタイプのアイコンとラベルの SSoT。ナビバー、ボトムバー、エディタすべてがこれを参照する。 ### 開発者モードと露出タグ([#1034](https://github.com/notedeck-dev/notedeck/issues/1034)) 開発者向けの面を既定で隠し、開発者モードで開放する。**隠すのは入口だけ**で、デッキに置かれたカラムや開いたウィンドウの描画は止めない。認可の境界でもない(認可は `permissions.json5` の principal — 開発者モードを off にしてもプラグインや AI の権限は変わらない)。 **AI は開発者向けではない。** 接続と権限の設定は一般側に出ていて、接続の組込テンプレートは AI プロバイダーで占められている。AI の存在は既定の顔に露出しているので、AI カラムとエージェント設定を隠すと「鍵は登録できるが使う場所が無い」袋小路になる。API キーを持つことも開発者に限らない。隠すのは API コンソール・API ドキュメント・ストリーム・スクラッチパッド・タスクと、Raw データを見る面・生ファイルを編集する面。 **配布物は「カラムは一般 / 作成・編集は開発者」。** テーマ・プラグイン・ウィジェット・クエリ・スキルが同じ規則に従う。管理カラムごと隠すと MisStore からの受け取りが死ぬ。 **帰属タグ:** カラム / ウィンドウ / コマンド / チュートリアルのカテゴリが `exposure` を持つ。未指定は `'general'` 扱いなので、既存の面は宣言を足さない限り従来どおり出る。判定は `src/settings/exposure.ts` の `isExposed()` 一本で、「アクティブなタグ集合が要求タグを含むか」を見る。複雑さの軸([#1024](https://github.com/notedeck-dev/notedeck/issues/1024))が来たらタグを 1 つ足して集合を広げる — boolean にしていないのはこのため。 **判定を参照する面(追加時はここに繋ぐ):** | 面 | 参照するもの | |------|------| | カラムの追加導線(追加ダイアログ / パレット / ナビバーエディタ / LaunchPad「もっと」) | `src/columns/exposure.ts` の列挙アクセサ | | ナビバーのボタン | `isColumnExposed`(`navbar.json5` からは消さず描画側で絞る) | | ウィンドウの入口(文脈メニュー・カード上の編集ボタン) | `src/windows/exposure.ts` の `isWindowExposed` | | 設定メニュー / 設定のクイックピック | 開くウィンドウのタグ(両者が自動で揃う) | | コマンドパレット / キーバインド割り当て一覧 | `Command.exposure` | | 生ファイルを直接編集するタブと、その「OS 既定エディタで開く」 | 各ウィンドウが宣言的に opt-in(`tests/lint/rawFileEditorExposure.test.ts` が宣言漏れを検査する) | | 配布物の作成・編集の入口(新規作成ボタン / カードの編集 / プラグインのソースタブ) | 編集ウィンドウがあれば `isWindowExposed`、窓自体は一般でタブだけ隠す場合(プラグイン)は `isExposed('developer')` | | 独立ウィンドウでない Raw 面(プロフィールの Raw、サーバー情報の meta/stats、チャートの JSON、連合インスタンス) | `isExposed('developer')` を直接参照 | ウィンドウの `open()` は塞がない。プラグイン・AI・`notedeck://` からの正当な呼び出しまで壊すと「機能の削除・劣化はしない」原則に反する。 **初期値:** 既存インストールは on、新規のみ off。判定に `settings.json5` のキー有無は使わない(スカラー設定を一度も変えていない既存ユーザーが新規と区別できなくなる)。アカウントの有無と `ai.json5` の痕跡を信号にして、起動時に一度だけ確定させる(`src/services/developerMode.ts` + `useDeveloperMode`)。 **トグル:** コマンドパレットのトグルコマンドと、「NoteDeck について」のスイッチ。設定メニューには置かない。読み書きは `useDeveloperMode` に集約している。 ### Note List: 表示述語とフェッチカーソル([#831](https://github.com/notedeck-dev/notedeck/issues/831)) 大原則は **データは保持し、表示時に決める**。取り込み時フィルタ・物理削除は採らないため、ミュート/凍結の解除で表示が即時に復活する。 **2 つの基底(`useNoteList`):** | ref | 内容 | 用途 | |-----|------|------| | `rawNotes` | unfiltered な書込基底(writable) | 全ての read-modify-write(マージ・挿入・削除・truncate)と**同期位置の決定** | | `notes` | `isHidden` 述語でフィルタした表示列(readonly) | 描画・**ユーザー可視状態の判定** | 読取の判別規則: `sinceId` / `untilId` / `hadNotes` / 空ガード / `refreshFetch` 引数 / キャッシュアンカーは `rawNotes`。skeleton 表示・エラー UI は `notes`。filtered を書込基底にすると、隠れているノートが書き戻しのたびに列から落ちて焼き込まれ、解除で復活しなくなる。 **規約**: `rawNotes` の setter を迂回してノートを挿入する経路を増やさない([#828](https://github.com/notedeck-dev/notedeck/issues/828) の凍結 probe が「新規挿入 1 点フック」で全経路を捕捉する前提)。 **フェッチカーソル(`useNoteColumn`):** `fetchCursor = { id, createdAt } | null` に、取り込んだ**生ページ**(`filterNotes` 適用前)の最古ノートを保持する。ページングの位置決めはバッファ末尾ではなくこれを使う。 - API `loadMore` の `untilId` = `fetchCursor.id ?? rawNotes 末尾` - キャッシュ `loadMore` のアンカー = `fetchCursor.createdAt ?? rawNotes 末尾の createdAt` - 単調前進: 生ページの最古が現カーソルより古いときだけ更新(最新側ページで新しい方向へ戻さない) - リセット: バッファを全置換する操作(reconnect / gap catch-up / タブ切替 / 非 streaming の refresh)で null にし、置換ページから貼り直す。旧世代カーソルが残ると新バッファとの間がサイレントにスキップされる バッファ末尾を位置決めに使うと、ページが丸ごとフィルタで落ちたとき位置が前進せず、`loadMore` が同じページを取り続ける無限ループになる。 **表示述語 `useNoteVisibility().isHidden(note, opts?)`:** 判定材料は 2 種類に分かれる。**対象由来**(ユーザーミュート [#574](https://github.com/notedeck-dev/notedeck/issues/574) / インスタンスミュート [#613](https://github.com/notedeck-dev/notedeck/issues/613) / リノートミュート [#614](https://github.com/notedeck-dev/notedeck/issues/614) / 凍結 [#828](https://github.com/notedeck-dev/notedeck/issues/828))は「誰か」に紐づき、**内容由来**(ワードミュート [#610](https://github.com/notedeck-dev/notedeck/issues/610) / 削除 tombstone [#602](https://github.com/notedeck-dev/notedeck/issues/602))は「何が書かれているか・存在するか」に紐づく。 `VisibilityOpts` は面ごとの opt-out(既定は全適用 = 安全側): | opt | 無視する材料 | 適用面 | |-----|------------|--------| | `ignoreSuspension` | 凍結のみ | 自分が保存した面(お気に入り・自クリップ・詳細の祖先スレッド) | | `ignoreSubject` | 対象由来を全て | 明示的に開いた面(プロフィール) | 面別マトリクス(本家の read-time フィルタ挙動に準拠): | 面 | opts | |----|------| | TL 全種 / リスト / アンテナ / チャンネル / ロール / メンション / explore / 検索 / 通知 | なし(全適用) | | お気に入り / クリップカラム(自分 + お気に入りしたクリップ) | `ignoreSuspension` | | クリップ詳細ウィンドウ | 自分のクリップなら `ignoreSuspension`、他人なら なし | | ノート詳細・Lookup の本体ノート | 述語を通さない | | ノート詳細・Lookup の祖先スレッド | `ignoreSuspension` | | ノート詳細・Lookup の返信ツリー | なし | | プロフィール(ノート / ファイル / ユーザーカラム) | `ignoreSubject` | カラム系は `NoteColumnConfig.visibility` で指定し `useNoteList` へ透過する。独自 ref の面は `filterVisible(notes, opts)` を表示用 computed で使う(書込基底は unfiltered のまま)。「見えているもの」の下流供給(`reportVisibleItems`)には述語適用後を渡す。 **凍結ストア(`stores/suspensions.ts`):** サーバーで凍結(または削除)されたユーザーの per-account 集合。mutes は「自分の意思」、こちらは「サーバー側の事実」なので相乗りさせない。SQLite キャッシュは触らないので、凍結解除で自動的に表示が戻る。 検知は `users/show({ userIds })` の 3 値判定 — 応答から欠落 = 凍結(非モデレーターにはサーバーが `isSuspended:false` で絞る)/ `isSuspended === true` = 凍結(モデレーター経路)/ 返却され false = 解除。取得失敗は無更新(fail-open)。 probe の供給は**挿入 1 点フック**にまとめてある(`useNoteList` の `rawNotes` setter / `DeckNotificationColumn` の `notifications` watch / `useCrossAccountNotes` の `rawNotes` watch)。経路を列挙しないので、ストリーミングやページングの取りこぼしが構造的に起きない。解除の検知はストア自身の周期タイマー(15 分・aging 付き)が担当し、カラム構成にも probe 発火にも依存しない。 ### Column Query([#783](https://github.com/notedeck-dev/notedeck/issues/783)) Krile 型「カラムごとのクエリフィルタ」。ユーザーは AiScript 1.2.1 の式(v1 サブセット)でカラムの視界を定義し、コンパイラが **QIR**(型付きクエリ IR、Rust が source of truth で specta 経由 bindings.ts に載る)へ落として全取り込み経路(キャッシュ復元 / REST / ページング / streaming / refresh)で同期評価する。表示制御 3 層モデル(#831)の層 2。 - **意味論の正本は AiScript 1.2.1**(不変条件 (a))。全評価器(JS QIR eval / Rust QIR eval / 降格 Interpreter)は共有 golden vector(`src/services/columnQuery/golden/vectors.json`)で一致を CI 検証する - 実装: `src/services/columnQuery/`(compiler / evaluator / referenceEvaluator / composeQir / degradedBatch / degradedRunner)。QIR 型・Rust 評価器・FTS プリフィルタ抽出は `src-tauri/src/commands/column_query.rs` - **実行形態は 2 つ**: コンパイル成功 = ⚡(QIR 同期評価 + インデックスを使ったキャッシュ検索)、サブセット外 = 🐢(専用 Web Worker で逐次適用)。🐢 でもできないのは高速検索だけで、表示の意味論は同じ。Worker バッチにはタイムアウトを張り、超えたら `terminate()` → 評価開始マーカーで犯人フィルタを特定してそれだけサスペンドする(AiScript の同期評価は abort できず step 予算もメモリを縛れないため、これが唯一の確実な停止手段)。サスペンド中を含むバッチは fail-closed、解除はユーザーの明示操作のみ - **ローカルキャッシュ検索**: ⚡ のカラムはページングでキャッシュを遡れる(`searchCachedNotesByQuery` → `qir_search_cache`)。FTS5 trigram で粗く絞ってから Rust QIR eval で最終判定する(プリフィルタは偽陰性を出さない = 不変条件 (b))。母集合はカラムの所属バケット。notecli の実体/所属分離(notecli#30)が前提で、それ以前は所属が後勝ち上書きのため種別で絞ると取りこぼし、全体走査に倒していた - **UX**: 定義・サイドロード・MisStore 導入はクエリ管理カラム(`queryManager`、ツール系)に一元化。適用はタイムラインカラムのフィルタメニューの「クエリ」トグル(`DeckColumn.noteQueryRefs` に id 参照、複数参照は And 合成)。カラムヘッダのバッジが実行形態(⚡ / 🐢 / ⚠)と per-note エラー件数を示す - 名前付きクエリはウィジェットと同じ sidecar 形式(`queries/.is` + `.meta.json5`、`useColumnQueriesStore`)。参照消失・コンパイル不能は **fail-closed**(構成は捨てず、カラムを保留 + 診断表示) - **スコープはプラグインと同型**(#1018)。クエリ管理カラムは全アカウント/per-account の両方で開け、開いた文脈がそのカラムの管理スコープになる。クエリ自体は純粋(アカウント状態を参照しない)だが、どのアカウントのカラムで選べるかを持たせてアカウントごとに使い分けられる。`global` / `installedFor`(`accountScopeKey`)のどちらも持たないものはライブラリのみで、ピッカーから各スコープへ追加する。適用側(フィルタメニューのクエリトグル)もスコープで絞るが、既に適用済みの参照は外れていても出す — 黙って消えると効いている理由が追えないため - **セーフモード(#794 W1)中は停止する** — 起動時に自動実行されるユーザーコードなのでプラグインと同じ扱い。`useNoteColumn` の最上流 1 点でゲートし、コンパイル・Worker 起動・キャッシュ検索のすべてに入らない。停止中はフィルタなし表示(**fail-open**)で、理由はクエリ管理カラムに出す。仕様当初の fail-closed 案からの変更(#966)。クエリを設定しているカラムでは、停止中もヘッダのクエリバッジを彩度を落として出し続ける — バッジごと消えると「もともとクエリを設定していないカラム」と区別がつかず、隠していたものが予告なく表示に戻ったことに気づけないため(#971) - MisStore 配布は**導入まで実装済み**(配布はソースのみ・ローカルで必ず再コンパイル = 不変条件 (e)、自動適用なし)。更新導線とソース差分の明示承認は Phase 3.5 の未実装分 ### Vue Vapor モード([#52](https://github.com/notedeck-dev/notedeck/issues/52))— 移行準備完了 Vue 3.6 の Vapor モード(仮想DOMレス・コンパイル時DOM操作)への移行準備が**完了**。 既知のブロッカーはゼロ。Vue 3.6 リリース時にそのまま有効化可能。 **コーディング制約(新規コンポーネントでも維持すること):** - `