Skip to content

Release v1.47.0 - #1047

Merged
hitalin merged 33 commits into
mainfrom
develop
Aug 12, 2026
Merged

Release v1.47.0#1047
hitalin merged 33 commits into
mainfrom
develop

Conversation

@hitalin

@hitalin hitalin commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

主な変更

設定ファイル名と表示名の分離 (#913)

ユーザーが付けた表示名(日本語含む)がそのままファイル名になっていた 5 種別を、「ファイル名は ASCII slug / 参照はファイル内 ID」に分離した。既存ファイルは初回起動時に自動で正規化される(ID は現在値のまま凍結するので、アクティブプロファイルやデッキ内の参照は無追随で整合する)。

  • 全 6 種別(プロファイル / テーマ / ウィジェット / プラグイン / カラムクエリ / スキル)を ID→ファイル名の対応表に載せ替え、リネームで ID が変わらないようにした
  • プロファイルの「ID = ファイル名」を解消(ファイルに ID を持たせ、表示名の変更はファイル名だけを追随させる)
  • 保存・削除・履歴・外部エディタ起動が表示名からファイル名を再計算する構造を全廃(孤児ファイル・削除の空振りの根絶)
  • ストア配布アイテムの同一性を storeId に統一(同名の自作があってもインストールできない・ウィジェットが更新できない・スキルの ID 乗っ取り経路といった不具合の修正)
  • バックアップ復元を atomic 化し、キー構造検証・大文字小文字衝突の検査を追加。復元時にスキップした項目は警告として表示する
  • custom.css の編集履歴が許可リスト漏れで一度も保存されていなかった不具合を修正
  • 重複 ID で読み込まれなかった設定ファイルをトーストで通知する

配布アイテムの更新検知と更新適用 (#1040)

MisStore からインストールしたテーマ / プラグイン / ウィジェット / クエリ / スキルに更新導線を追加した。

  • 記録済みハッシュとレジストリの比較で「更新あり」を検知(バージョン文字列には依存しない)。カードに更新バッジと更新ボタンを表示
  • 更新の適用前に、変更内容を差分で確認できるようにした(feat: AI の編集を承認する前に差分で見せる #981 の共通 diff コンポーネント)。プラグインの権限が増える更新は明示して確認する(追加インストール経路も同様)
  • ストア更新直後にハッシュ不一致が出た場合、改ざん警告の前に一度だけ再取得してから判定する

削除操作の見分け (#1048)

同じゴミ箱アイコンが場所によって「配置から外す」と「本体を削除」の別物になっていた問題を整理した。

  • サイドバーのウィジェットを外すと、確認なしでコードごと消えていた不具合を修正(外す操作は本体を消さない。本体削除はライブラリからの操作に一本化)
  • 可逆な「外す」は専用アイコンに変え、ゴミ箱は本体削除だけに使う。5 種別で語彙を統一
  • 外す操作にも「元に戻す」を出す

その他

関連

Closes #913
Closes #1040
Closes #1048

hitalin and others added 25 commits August 12, 2026 05:35
仕様確定版の「規約適合 = slugify の不動点」を実装する純関数群。
小文字 [a-z0-9-]・48 文字上限・Windows 予約デバイス名回避・
連番 suffix (-2 昇順、truncate 後付与) を単一モジュールに集約し、
生成物が常に適合判定を満たすことをテストで保証する。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
JSON5 メタ / skill frontmatter への凍結 ID 注入。パースした生内容
への追記のみ (決定的・コメント整形保持・後勝ちで不正値上書き) で、
達成済み判定の内容一致が揮発デフォルトで偽陰性にならないことを
保証する。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
widgets/plugins/columnQueries 共通の sidecarFileCollection を #913 仕様へ
書き換える:

- loadAll はファイル名の辞書順 (UTF-8 バイト順) で決定的に処理し、各
  アイテムに runtime-only の実ファイル基底名 fileBase を保持する
- ID 凍結を常設規則化: メタの ID 欠損 (キー不在・空・非文字列・256 文字超。
  制御文字は含めない) はメタファイル完全名を injectJson5Id の最小変換で
  書き戻す (冪等・コメント保持)
- 同一 ID の 2 件目以降は警告 + 読込スキップ (ファイルは削除しない)
- 空ソースフォールバック廃止: 「メタあり・ソースなし」はミラー本文からの
  再作成、無ければ readOnly (persist 抑止) で可視化
- persist/delete/rename は fileBase 参照 (name からの再計算を全廃)。
  新規割当は slugifyName + resolveAvailable。占有判定は
  対応表 ∪ 実列挙 (.is/.meta.json5/.history.json5) ∪ ID 集合を casefold 照合
- 書込順 src → meta / 削除順 meta → src (+ 履歴サイドカーも削除)。
  rename は src → meta → history、casefold 一致の自己リネームは中間名 2 段
- migrateItems: 規約外名の copy-adopt (生内容 + 凍結 ID・読み戻し検証・
  達成済み判定・suffix 退避)。sweepHistory: 実列挙の主ファイル basename と
  対応の取れない .history.json5 を削除

settingsFs には renameQueryFile と isMainDeckWindow (移行の実行主体判定) を
追加し、不要になった per-kind ファイル名ヘルパーを削除。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 各 store に「初回読込 (対応表確定) + 初回移行」を待つ ready ゲートを
  設け、変更系操作 (新規作成・リネーム・保存・削除) のファイル反映を
  移行完了後へ直列化する
- initFileStorage で (a) 規約外名の copy-adopt 正規化 → (b) ミラー在・
  ファイル不在の新 slug 再作成 (空ソースは書かない) → 履歴 sweep を
  メインウィンドウのみで実行 (isMainDeckWindow・冪等)
- リネームを「旧 baseName 削除 + 新名 persist」から rename コマンド追随
  (src → meta → history、ID 不変) に置換し、完了を await してから保存する
- 保存・削除の直前に localStorage ミラーの対応表を読み直す
  (別ウィンドウのリネーム後の削除が stale 名で空振りしないように)
- 履歴 push/list/revert のキーを fileBase に変更、readOnly 個体の
  src 編集を抑止、外部エディタ起動 (PluginsContent) を fileBase 参照に変更

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
テーマ (.ndtheme.json5) / スキル (.md) 共通の「単一ファイルで 1 アイテム」
永続化サービス。sidecarFileCollection と同じ #913 不変条件を実装する:

- loadAll はファイル名の辞書順 (UTF-8 バイト順) で決定的に処理し、各
  アイテムへ runtime-only の fileBase (ID → 実ファイル名の対応表) を付与
- ID 凍結 (常設規則): ID 欠損 (キー不在・空・非文字列・256 文字超) なら
  種別の実効値を idFreeze の最小変換で書き戻す (冪等・コメント保持)
- 重複 ID は 2 件目以降を警告 + skip (ファイルは削除しない)
- 移行 (a) は copy-adopt (読む → 新 slug 名へ生内容を書込 → 検証 → 旧削除)。
  達成済み判定 (ID 一致 + 内容一致 → 旧削除のみ / ID 一致・内容不一致 →
  削除せず suffix + 警告) と case-only 中間名 2 段を含む
- 占有判定は casefold + 実列挙 ∪ 対応表 ∪ (ID を決める操作では) ID 集合
- 履歴 sweep は実列挙の主ファイル basename 集合と casefold 照合

パース形式 (JSON5 / frontmatter) と凍結実効値は config フックで注入し、
テーマとスキルで共用する。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- themeFileSync を singleFileCollection ベースに書き換え、theme
  オブジェクトの runtime-only な fileBase を対応表とする (ファイルへは
  projection で書かず、localStorage ミラーには同乗)
- ID 凍結 (実効値 custom-<完全ファイル名>)・重複 ID skip・辞書順読込
- initFileStorage で (a) 規約外名の copy-adopt 正規化 → (b) ミラー在・
  ファイル不在の新 slug 再作成 (旧 localStorage 片方向移行を統合) →
  履歴 sweep をメインウィンドウのみで実行
- 変更系操作は ready ゲート (初回読込 + 移行完了) 待ちに直列化し、
  保存・削除の直前にミラーの対応表を読み直す
- renameTheme を「旧削除 + 新書込の並行発火」から rename コマンド追随
  (主ファイル + 履歴、ID 不変・完了 await 後に persist) へ置換
- 手動テーマ貼り付けの同一 ID 更新で、貼り付けコードに $notedeck が
  無ければ既存の $notedeck (storeId 等) を保持する
- 履歴キー (pushSnapshot / theme.history / theme.revert) を theme.id
  から fileBase 参照に変更
- 外部エディタ起動 (ThemeEditorContent) を対応表参照に変更、
  themeFilename / listThemes を廃止 (β 方針: 後方互換は残さない)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- skills store を singleFileCollection ベースに書き換え、SkillMeta の
  runtime-only な fileBase を対応表とする (frontmatter には書かない)
- ID 凍結の実効値 = 拡張子を除いた basename (現行 fallbackId と同値)、
  重複 ID は 2 件目以降を skip (ファイルは削除しない)
- initFileStorage で (a) 規約外名の copy-adopt 正規化 → 履歴 sweep を
  メインウィンドウのみで実行。スキルは本文ミラーが無いため (b) 再作成は
  非適用 (外部削除 = 削除確定)。builtin seed / storeMoved migrate は
  既存挙動を維持
- 変更系操作 (add / update / remove) を ready ゲート (初回読込 + 移行
  完了) 待ちに直列化。新規作成の fileBase は表示名 slug から割当
  (ファイル名は ID からでなく対応表から)
- 表示名変更はファイル rename で追随 (ID 不変・主ファイル + 履歴、
  完了 await 後に persist)。削除は主ファイル + 履歴を消す
- 履歴キー (pushSnapshot / skills.history / skills.revert) を
  name || id から fileBase 参照に変更
- 外部エディタ起動 (SkillEditContent) を対応表参照に変更、
  skillFilename / listSkillFiles を廃止 (β 方針: 後方互換は残さない)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
builtin seed のスキルはテンプレ id (slug 適合) をそのままファイル名に
する。表示名 slug だとフレッシュインストールで notedeck.md /
notedeck-2.md / skill.md に化けて意味が消えるため。占有時は連番、
規約不適合なら表示名 slug に落ちる。sidecar 側にも同じフックを足し、
PR-3 のストアインストール (ファイル名 = storeId) が同じ経路に乗れる
ようにする。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
唯一「ID = ファイル名」で結合していたプロファイルを
singleFileCollection の対応表 (fileBase) 方式へ移す。

- codec: id をファイルに書き、parse 済み raw を土台に既知フィールドを
  上書きして未知フィールドを保持する (ダウングレード往復の剥がれ防止)。
  fileBase は runtime-only でファイルへ書かない
- ID 凍結: id 欠損は拡張子込みの旧完全ファイル名を実効値として注入。
  既存の nd-deck-active-profile / ?profile= / windowProfileId は無追随で
  生き続ける
- リネーム = 表示名変更のみ (ID 不変)。ファイルは rename 追随し、完了を
  await してから保存する (旧 rename/persist 並行発火の順序バグ根絶)
- 移行 (a) 規約外名の copy-adopt / (b) ミラー在・ファイル不在の再作成を
  メインウィンドウのみで実行。memOnly マージ同定を「ID 一致 or
  名前+作成日時一致」に拡大
- 新規作成は slug 形式の ID + slug ファイル名。変更系操作は ready ゲート
  (初回読込 + 移行完了) を待つ。保存・削除の直前にミラーの対応表を読み直す
- 外部エディタ起動は対応表のファイル名で開く

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
skillsFileSync.test の先例に倣い、settingsFs をインメモリ疑似 FS で
モックして store 経由の不変条件を保証する:

- ファイル内 id の採用と fileBase 対応表の保持
- id 欠損の凍結 (= 旧完全ファイル名) と規約外名の copy-adopt 正規化
- 凍結による activeProfileId / windowProfileId / デッキ内容の無追随整合
- リネームの ID 不変 + ファイル rename 追随
- 新規作成・デフォルトプロファイルの slug ファイル名/ID
- 移行 (b) ミラー再作成と memOnly マージ同定の拡大
- 保存・削除直前のミラー対応表読み直し (別ウィンドウのリネーム追随)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
custom.css.history.json5 が root allowlist から漏れており、フロントの
settingsFs 履歴系がこの名前で read/write を試みて常に reject されていた。
回帰テストは次コミットの import_bundle 強化テスト群と同居。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
import_bundle を強化する:

- キー構造検証: 「許可サブディレクトリ + ファイル名」の 2 要素、または
  許可ルートファイルの 1 要素のみ。3 要素以上のネストは拒否。サブディレクトリ
  側のファイル名には強化検証 (制御文字・Windows 予約デバイス名 (stem 判定)・
  先頭ドット・末尾ドット/空白) を適用。文字種 (日本語等) は寛容のまま —
  旧バックアップの復元を拒否しない。validate_filename 本体は強化しない
  (規約外名の既存ファイルの読取・移行が成立しなくなるため)
- casefold 衝突検査 + 排他書込: 書込先ディレクトリの実列挙 + ASCII casefold
  の事前照合の上で、create_new 予約 → atomic_write (tmp + rename)。FS 自身の
  同名解決 (非 ASCII casefold・NFC/NFD) を衝突検出の正とし、case-insensitive
  FS で旧バックアップが正規化済みファイルを truncate 置換するのを防ぐ
- 衝突判定・skip/suffix は basename グループ (ソース + メタ + 履歴) 単位。
  全構成が内容バイト一致ならグループ skip、不一致なら全構成を同一の -2 昇順
  suffix (複合拡張子の前に挿入) へ退避し、ファイル内 ID は変えない
- sessions/ 配下は 0o600 を維持。素の fs::write を全廃
- 許可ルートファイルは固定名の単一ファイルなので復元 = atomic 置換
  (suffix 退避先は allowlist 外で二度と読まれないため衝突分岐は不適用)
- import_settings_json がスキップ / 別名退避の警告リストを返し、
  BackupContent.vue が再起動前に件数 + 内容を開示する

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ストアインストール経路の同一性統一 (misstore 側は次コミット) の store 基盤:

- widget/plugin/query のメタと skill frontmatter、テーマ $notedeck に
  storeSha512 / storeVersion を追加 (更新検知 #1040 の baseline。
  既存インストール品の未記録はそのまま — ここでは記録し始めるだけ)
- 各コレクション config に preferredBase: storeId を配線し、
  新規ストアインストールのファイル名 = storeId にする (規約不適合は
  自動で表示名 slug に落ち、占有時は連番 suffix)
- applyStoreUpdate を widgets/plugins/columnQueries に追加: 本体 (src) と
  ストア由来メタを上書きし、ローカル値 (改名 name・active・スコープ・
  configData・autoRun) は維持。ソース欠損の readOnly 個体は検証済み
  配布ソースで復旧する
- persist 経路で live 要素 (reactive proxy) を渡すよう修正: raw 参照だと
  占有判定の「自分自身は占有とみなさない」が崩れ、preferredBase =
  id/storeId のファイル名に無意味な -2 suffix が付く (builtin seed にも
  効く潜在バグ)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
既存判定・更新判定の照合キーを全種別で storeId のみに統一し、
「storeId 一致の既存あり → 上書き更新 / なし → 新規作成」に揃える:

- installSkill: frontmatter の id 宣言優先を廃止。新規のローカル ID =
  storeId (レジストリのディレクトリ名が正)、既存への更新はローカル ID
  維持。storeId 不一致の既存 ID に占有されていたら上書きせず連番 suffix
- installTheme: t.id === entry.id フォールバックを廃止。更新は配布内
  UUID が変わってもローカル ID・ローカル改名を維持して既存を置換
  (既存 fileBase のファイルへ書く)
- installPlugin: isDuplicate(name) の名前重複拒否を廃止 (同名自作が
  あってもインストール可能 — ファイル名は suffix が捌く)。linkScope-only
  も廃止し、本体とストア由来メタを上書き + scope 追加
- installWidget: 既存ありの early return を廃止し上書き更新に
- installQuery: 更新で name を渡さない (ローカル改名維持)
- isInstalled / isThemeInstalled / isSkillInstalled: name・内部 ID
  照合の残りを全廃 (deep link の同定もこの経路で storeId に統一)
- 全種別でインストール/更新時に storeSha512 (computeSha512 で照合済みの
  値) と storeVersion を記録

更新で維持されるローカル値: 有効/無効・実行モード・スコープ・設定値・
autoRun・ローカル改名 (name は新規時のみ entry 値を採用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
widget/plugin/query も新規インストール時のローカル ID = storeId とする
(仕様確定版の「スキル含む全種別」)。アンインストール → 再インストール
でカラム参照 (widgetIds / noteQueryRefs) が生き残る。既存 ID との衝突
時のみ連番 suffix (skills と同じガード)。既存インストール品の ID は
凍結どおり不変。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
仕様の「警告 + UI 通知」のうち UI 通知が console.warn 止まりだった
残件。コレクションに notify フックを足し、全 6 種別の設定を
警告トーストへ配線する (ユーザー操作なしには解消しない状態のため)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codemirror/merge の unified ビューで oldText/newText の全文 diff を
読取専用表示する。言語モードは既存基盤 (AiScript 自前モード /
lang-json / lang-markdown / lang-css) から解決し、挿入/削除色は
グローバル CSS 変数で開放する。未変更領域は既定で折りたたむ。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
diff 指定時は code ブロックの代わりに CodeDiffView を描画する
(併用時は diff 優先)。attribution・remember 等の既存挙動は不変。
ダイアログ内は最大高さを制限して diff 側にスクロールを持たせる。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
インストール時に記録した storeSha512 と registry 現行 sha512 の比較で
更新を検知する (version 文字列は bump が機械強制されていないため判定に
使わない)。storeSha512 未記録の既存インストール品は、レジストリ照会時に
現行値を無通知で基準記録し、次の変更から検知を始める (誤って「全件
更新あり」にしない)。各 store には履歴 push を伴わない
recordStoreBaseline を追加した。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
配布アイテム 5 種の更新適用 (updatePlugin/Theme/Widget/Skill/Query) を追加。
ソース fetch + sha 照合の後、ConfirmOptions.diff で適用前の全文 diff を確認し
(old = ローカルの現在の本体、new = 適用後の本体)、承認されたら確認に使った
内容をそのまま適用する — 承認後の再 fetch・再計算はしない (#981 不変条件)。
表示の主はレジストリ updatedAt、version は補助。

再同意: プラグインの permissions が拡大する更新は「新しい権限: xxx」を
warning で明示する。既存記録が無いなど判定不能な変化は拡大側に倒す。

リトライ: インストール/更新でソース sha が entry.sha512 と不一致のとき、
改ざん警告の前にレジストリ index をキャッシュ無効化して再取得し 1 回だけ
リトライする (ストア更新直後の TTL 内は正常な更新が偽警告になるため)。
install 系 5 経路も同じ検証付き取得 (fetchVerifiedSource) に統一した。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
インストール済み + ストア側更新ありのカードで「インストール済み」表示を
「更新あり」バッジ + 「更新」ボタンに置き換える。5 種 (ウィジェット /
プラグイン / テーマ / クエリ / スキル) で同じ文言・同型の accent チップ。

- 更新ボタンは store の updateXxx を呼ぶだけ (diff 付き確認・権限拡大の
  明示・sha 照合は store 側)。実行中は installing 系 ref で無効化
- バッジ/ボタンの tooltip は主 = レジストリ updatedAt、補助 = version
- テーマは grid タイルの流儀に合わせ、オーバーレイチップ + 丸ボタン。
  カードクリックも更新に接続 (インストールと同じ入口)。更新は
  installedFor に触れず既存の適用範囲を維持
- WidgetCard の dom テストを追加 (更新ボタン表示 / update emit / 無効化)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

API surface diff

外部アプリ向け API 面 (src-tauri/openapi.json / src/bindings.ts) が変更されています。
互換性への影響 (#709) をレビューしてください。

src-tauri/openapi.json | 2 +-
 src/bindings.ts        | 8 +++++++-
 2 files changed, 8 insertions(+), 2 deletions(-)
Full diff
diff --git a/src-tauri/openapi.json b/src-tauri/openapi.json
index 040f21b..79287a7 100644
--- a/src-tauri/openapi.json
+++ b/src-tauri/openapi.json
@@ -6,7 +6,7 @@
     "license": {
       "name": "MIT"
     },
-    "version": "1.46.2"
+    "version": "1.47.0"
   },
   "paths": {
     "/api": {
diff --git a/src/bindings.ts b/src/bindings.ts
index d697552..fe068e4 100644
--- a/src/bindings.ts
+++ b/src/bindings.ts
@@ -2329,7 +2329,7 @@ async exportSettingsJson() : Promise<Result<boolean, { code: string; message: st
  *
  * @see src-tauri/src/commands/settings.rs
  */
-async importSettingsJson() : Promise<Result<boolean, { code: string; message: string; apiCode: string | null }>> {
+async importSettingsJson() : Promise<Result<ImportSettingsResult, { code: string; message: string; apiCode: string | null }>> {
     try {
     return { status: "ok", data: await TAURI_INVOKE("import_settings_json") };
 } catch (e) {
@@ -3223,6 +3223,12 @@ export type HttpFetchResponse = { status: number; headers: Partial<{ [key in str
  * 画像ディスクキャッシュの使用量 (#815)。設定のキャッシュ画面で表示する
  */
 export type ImageCacheStats = { bytes: number; files: number }
+/**
+ * import_settings_json の結果。`imported: false` はダイアログのキャンセル。
+ * `warnings` はスキップ / 別名退避したエントリの説明 (#913 付随修正 — フロントは
+ * 復元完了メッセージに件数 + 内容を表示する)。
+ */
+export type ImportSettingsResult = { imported: boolean; warnings: string[] }
 export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }>
 /**
  * Misskey の `mutedWords` / `hardMutedWords` の 1 要素。

@github-actions github-actions Bot added rust Pull requests that update rust code javascript Pull requests that update javascript code labels Aug 12, 2026
@socket-security

socket-security Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​codemirror/​merge@​6.12.21001009287100
Updatedcargo/​lru@​0.16.4 ⏵ 0.18.210010093100100

View full report

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The release adds stable slug-based settings persistence, validated settings import warnings, registry metadata and update flows for distributed items, read-only diff confirmation, and file-base-aware history and external-editor handling.

Changes

Settings persistence and migration

Layer / File(s) Summary
Slug and collection persistence foundation
src/services/*, src/utils/settingsFs.ts, src/stores/deckProfile.ts
Added slug allocation, ID freezing, serialized file operations, collision handling, migration, history cleanup, and profile persistence.
Store-backed persistence integration
src/stores/plugins.ts, src/stores/skills.ts, src/stores/theme.ts, src/stores/widgets.ts, src/stores/columnQueries.ts
Migrated stores to shared collections with runtime fileBase, readiness gates, registry hash/version metadata, read-only handling, and history-aware renames.
Validated settings import flow
src-tauri/src/settings_store.rs, src-tauri/src/commands/settings.rs, src/bindings.ts, src/components/window/BackupContent.vue
Added validated atomic bundle import, collision warnings, structured results, and warning display before relaunch.

Store updates and confirmation UI

Layer / File(s) Summary
Verified store installation and updates
src/stores/misstore.ts, src/stores/*test.ts
Added store-ID matching, SHA-512 verification with retry, baseline recording, update detection, and confirmed update application.
Update UI, history lookup, and external files
src/components/deck/*, src/components/common/*, src/capabilities/builtins/*, src/components/window/*
Added update badges and actions, CodeMirror diff rendering, file-base history lookup, and persisted external-file resolution.
Release metadata and dependency wiring
package.json, src-tauri/Cargo.toml, src-tauri/openapi.json, src-tauri/tauri.conf.json
Updated the release version to 1.47.0 and added @codemirror/merge.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address the filename and identity requirements in [#913] and the detection, diff, permission, and retry flows in [#1040].
Out of Scope Changes check ✅ Passed The reviewed changes are related to the release, [#913], or [#1040], with no unrelated implementation identified.
Title check ✅ Passed The title clearly identifies this changeset as the v1.47.0 release.
Description check ✅ Passed The description clearly explains the main changes, rationale, and linked issues, but it omits the template's test checklist and screenshots section.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploying notedeck with  Cloudflare Pages  Cloudflare Pages

Latest commit: bdacec5
Status: ✅  Deploy successful!
Preview URL: https://d735d079.notedeck-d3a.pages.dev
Branch Preview URL: https://develop.notedeck-d3a.pages.dev

View logs

@hitalin hitalin self-assigned this Aug 12, 2026
lru 0.16 の LruCache::pop() が panic 安全でなく、キーの Drop が
panic すると解放済みノードがリンクリストに残り use-after-free に
なりうる (0.18.2 で修正)。API 変更はなく、既存の image_cache /
ogp の利用箇所はそのまま動く。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (10)
src/services/singleFileCollection.test.ts (1)

444-549: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

移行テストに履歴サイドカーのケースがありません。

renameItemFiles のテストは履歴サイドカーの追随を検証しますが (Line 378-392)、migrateItems のテストは主ファイルだけを置いています。このため src/services/singleFileCollection.tscopyAdoptOne が履歴を移送しない欠落を検出できません。Bad Name.history.json5 を含むケースを追加してください。

💚 追加テスト案
+  it('copy-adopt は履歴サイドカーも新 basename へ追随させる', async () => {
+    const fs = makeFakeFs({
+      [`Bad Name${EXT}`]: file('i1', 'Bad Name'),
+      'Bad Name.history.json5': '{ entries: [1] }',
+    })
+    const col = makeCollection(fs)
+    const { items } = await col.loadAll()
+    await col.migrateItems(items)
+    expect(items[0]?.fileBase).toBe('bad-name')
+    expect(fs.files.has('bad-name.history.json5')).toBe(true)
+    expect(fs.files.has('Bad Name.history.json5')).toBe(false)
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/singleFileCollection.test.ts` around lines 444 - 549,
migrateItems の移行テストに履歴サイドカー追随のケースを追加してください。特に copy-adopt 経路を検証するため、`Bad Name`
の主ファイルと `Bad Name.history.json5` を用意して `migrateItems` を実行し、新しい slug
側へ履歴内容が移送され、旧名側の主ファイルと履歴サイドカーが削除されることを確認してください。
src/stores/deckProfileFileSync.test.ts (1)

229-245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the delete-then-undo file outcome.

The delete test asserts only that the file disappears. deleteProfile returns an undo callback that calls saveProfiles, which re-writes files through a separate ready continuation. That interleaving is the risk described in src/stores/deckProfile.ts.

Add a case that calls the returned undo callback immediately after deleteProfile and asserts that the file exists afterwards.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/deckProfileFileSync.test.ts` around lines 229 - 245, The delete
test in deckProfileFileSync.test.ts only verifies file removal; extend it to
capture the undo callback returned by deleteProfile, invoke it immediately, and
then wait for and assert that the profile file exists again. Keep the existing
setup and deletion assertion as appropriate, and cover the saveProfiles/ready
continuation outcome.
src/stores/deckProfile.ts (1)

591-601: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the merge predicate into src/services/.

Lines 595-601 implement a merge rule: a mirror profile is memory-only when no file profile matches by id or by name + createdAt. This is pure logic. It currently has no direct unit test and is reachable only through the store.

Extract it as a named function in src/services/ (for example next to deckProfileFiles) and unit test it directly. Keep the store limited to subscription, cache, and UI state.

Based on learnings: 「正規化、マイグレーション、マージ規則、ファイル codec などの純ロジックは store に置かず src/services/ に実装し、直接ユニットテストする。store は購読、キャッシュ、UI 状態に限定する」.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/deckProfile.ts` around lines 591 - 601, The merge predicate
currently embedded in the store should be extracted into a named pure function
under src/services/, near the existing deck profile file logic such as
deckProfileFiles. Move the “matches by id or by name and createdAt” filtering
rule into that function, update the store to call it, and add direct unit tests
covering the matching and memory-only cases.

Source: Learnings

src/components/window/PluginsContent.vue (1)

96-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The four external-file call sites handle a missing fileBase differently. Three windows return a descriptor with disabled: true, which keeps a visible but inactive affordance. One window returns null, which removes the affordance. The user sees two different behaviours for the same "file not yet created" state. Pick one convention and apply it everywhere.

  • src/components/window/PluginsContent.vue#L96-L101: return { name: '', subdir: 'plugins', disabled: true } when p.fileBase is unset, instead of null.
  • src/components/window/SkillEditContent.vue#L34-L43: keep the disabled form; no change needed if the disabled convention is chosen.
  • src/components/window/ProfileEditorContent.vue#L112-L128: keep the disabled form; no change needed if the disabled convention is chosen.
  • src/components/window/ThemeEditorContent.vue#L73-L83: keep the disabled form; no change needed if the disabled convention is chosen.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/window/PluginsContent.vue` around lines 96 - 101, The
external-file call sites use inconsistent missing-file behavior. In
src/components/window/PluginsContent.vue lines 96-101, update the file
descriptor logic to return the disabled descriptor with an empty name when
p.fileBase is unset instead of null. Keep the existing disabled forms unchanged
in src/components/window/SkillEditContent.vue lines 34-43,
src/components/window/ProfileEditorContent.vue lines 112-128, and
src/components/window/ThemeEditorContent.vue lines 73-83.
src/stores/columnQueries.ts (1)

272-294: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

applyStoreUpdate overwrites description and iconUrl with undefined when the registry omits them.

...patch includes the optional keys only when the caller passes them. The caller in src/stores/misstore.ts (lines 1091-1096) always passes description: e.description and iconUrl: e.iconUrl, so a registry entry without these fields clears the stored values. This is consistent with "store-owned fields follow the registry", but it also erases values that an older registry version supplied. Confirm this is intended.

Consider dropping undefined keys if the intent is to keep the previous values:

♻️ Optional: keep previous values for omitted fields
     const next: NamedQueryMeta = {
       ...prev,
-      ...patch,
+      ...Object.fromEntries(
+        Object.entries(patch).filter(([, v]) => v !== undefined),
+      ),
       readOnly: undefined,
       updatedAt: Date.now(),
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/columnQueries.ts` around lines 272 - 294, Update applyStoreUpdate
so undefined optional patch fields do not overwrite existing NamedQueryMeta
values when registry entries omit them. Preserve prev.description and
prev.iconUrl unless the patch provides defined replacements, while continuing to
apply other patch fields and persist the resulting next value.
src/stores/plugins.ts (1)

611-654: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

applyStoreUpdate keeps configData entries for configuration keys that the update removed.

The loop adds defaults for new keys. It does not delete values for keys that no longer exist in patch.config. The stale values stay in memory and are written to the .meta.json5 file. This is harmless for behavior, but it grows the persisted metadata over successive updates.

♻️ Optional: drop values for removed configuration keys
     if (patch.config) {
+      for (const key of Object.keys(plugin.configData)) {
+        if (!(key in patch.config)) delete plugin.configData[key]
+      }
       for (const [key, def] of Object.entries(patch.config)) {
         if (!(key in plugin.configData)) plugin.configData[key] = def.default
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/plugins.ts` around lines 611 - 654, Update applyStoreUpdate so
plugin.configData is synchronized with patch.config: retain values for keys
still defined, add defaults for new keys, and remove entries whose configuration
keys were removed. Preserve existing behavior when patch.config is absent.
src/stores/columnQueries.test.ts (1)

54-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for recordStoreBaseline.

The new suite covers applyStoreUpdate. The store also exposes recordStoreBaseline, which must record storeSha512 and storeVersion without changing src or updatedAt. That silent-baseline contract is the basis for update detection in src/stores/misstore.ts (recordQueryBaselines). A regression there would silently disable update detection.

I can generate the test if you want.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/columnQueries.test.ts` around lines 54 - 58, Add a test suite for
the store’s recordStoreBaseline method alongside the applyStoreUpdate tests,
verifying it records storeSha512 and storeVersion while leaving src and
updatedAt unchanged. Ensure the test covers the baseline behavior used by
recordQueryBaselines in misstore.ts.
src/capabilities/builtins/plugins.ts (1)

467-467: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the history basename derivation into one helper.

The expression fileBase ?? (name || installId) is now repeated at Line 467, Line 486, and Line 536. The three sites must stay identical, otherwise plugins.history and plugins.revert read different history files. A single helper removes that drift risk.

♻️ Optional: single derivation point
+function historyBasename(plugin: PluginMeta): string {
+  return plugin.fileBase ?? (plugin.name || plugin.installId)
+}

Then use historyBasename(plugin) at all three sites.

Also applies to: 486-486, 536-536

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/capabilities/builtins/plugins.ts` at line 467, Extract the repeated
plugin history basename expression into a shared historyBasename(plugin) helper,
preserving the existing fileBase ?? (name || installId) precedence. Replace the
derivation at all three sites in the relevant plugin history/revert flows with
calls to this helper so every operation uses the same basename logic.
src/components/deck/PluginCard.vue (1)

69-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The updateTitle computed is copied verbatim into two store cards. Both cards derive the same tooltip from updatedAt and version with identical string templates. One shared composable removes the drift risk and gives a single place to verify the formatDate input type.

  • src/components/deck/PluginCard.vue#L69-L76: replace the local updateTitle computed with a shared composable, for example useStoreUpdateTitle(() => props.updatedAt, () => props.version).
  • src/components/deck/QueryCard.vue#L65-L72: remove the duplicate computed and call the same composable.

Confirm that formatDate accepts the ISO string that the registry supplies in updatedAt. If it expects a Date or an epoch number, both tooltips render Invalid Date.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/deck/PluginCard.vue` around lines 69 - 76, Extract the
duplicated updateTitle logic into a shared useStoreUpdateTitle composable, then
replace the local computed in src/components/deck/PluginCard.vue lines 69-76 and
src/components/deck/QueryCard.vue lines 65-72 with calls passing updatedAt and
version accessors. Ensure the composable verifies or converts the
registry-provided ISO updatedAt value to the input type expected by formatDate,
while preserving the existing tooltip text and empty-title behavior.
src/stores/widgets.ts (1)

400-428: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

applyStoreUpdate clears a local iconUrl when the registry entry omits it.

Line 421 assigns patch.iconUrl unconditionally. If the registry entry has no iconUrl, the stored value becomes undefined and the widget loses its icon after an update. installWidget in src/stores/misstore.ts uses ...(e.iconUrl ? { iconUrl: e.iconUrl } : {}) for new installs, so the two paths differ. Confirm that clearing is intended; otherwise assign only when patch.iconUrl is defined.

🛠️ Proposed fix
-    widget.iconUrl = patch.iconUrl
+    if (patch.iconUrl !== undefined) widget.iconUrl = patch.iconUrl
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/widgets.ts` around lines 400 - 428, Update applyStoreUpdate so
widget.iconUrl is changed only when patch.iconUrl is defined, preserving the
existing stored icon when the registry omits it. Keep the remaining metadata
assignments and persistence behavior unchanged, matching installWidget’s
conditional icon handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/settings_store.rs`:
- Around line 431-441: Update import_group’s reservation-backed write paths
around reserve_all and atomic_write so any member write failure releases all
group reservations before propagating the error. Apply this to both write loops,
including the later fallback path, while preserving the existing successful
cleanup and return behavior described by import_bundle.

In `@src/components/common/CodeDiffView.vue`:
- Around line 97-134: Replace the hardcoded `#1e1e1e` and `#9d9d9d` values in
.diffView and :global(.cm-collapsedLines) with global CSS variables, define
those variables in src/styles/global.css with appropriate light/dark theme
values, and preserve the existing color-mix behavior for the collapsed-line
background while using the new surface variable.

In `@src/components/deck/DeckSkillColumn.vue`:
- Around line 218-221: Validate or normalize each entry returned by
fetchSkills() before assigning it to StoreSkillEntry[]. Ensure updatedAt is
present and represents a valid date, rejecting or safely normalizing invalid
registry entries so storeUpdateTitle() never receives missing or invalid date
data.

In `@src/components/deck/PluginCard.vue`:
- Around line 183-194: Update the update button in PluginCard’s
alreadyInstalled/hasUpdate branch to disable when either installing is true or
capabilityOk is false, matching the install button’s guard and preventing
updates to incompatible versions.

In `@src/components/deck/ThemeCard.vue`:
- Around line 121-129: Update the update button in ThemeCard.vue, identified by
the v-if="alreadyInstalled && hasUpdate" condition, to bind its disabled state
to installing so repeated clicks cannot emit concurrent update events.

In `@src/services/idFreeze.ts`:
- Around line 104-113: Update injectFrontmatterId to derive the opening
frontmatter delimiter length from the matched text, rather than assuming four
characters, so innerEnd remains correct for both LF and CRLF input. Add a CRLF
coverage case in the idFreeze tests verifying the injected ID preserves the
final frontmatter line and document body.

In `@src/services/singleFileCollection.ts`:
- Around line 239-253: Update renameFileSet and its callers so item.fileBase is
changed immediately after the primary file rename succeeds, including each
intermediate case-only rename. Handle the HISTORY_SUFFIX rename independently:
catch failures, emit a warning, and do not roll back or throw after the primary
rename has completed. Preserve the existing target-exists skip behavior.
- Around line 301-392: Update copyAdoptOne in
src/services/singleFileCollection.ts (lines 301-392) to move the old basename’s
.history.json5 sidecar to the finalized basename after main-file migration; skip
when the old sidecar is absent or the destination already exists, including the
case-only two-step and already-adopted branches. Add a migrateItems test in
src/services/singleFileCollection.test.ts (lines 444-549) that creates a
noncanonical main file with its history sidecar and verifies the history exists
under the new basename.

In `@src/stores/deckProfile.ts`:
- Around line 571-583: Update the Tauri initialization flow in ensureDefaults
and initFileStorage so initialized.value is set to true even when
initFileStorage rejects, before resolving readiness in the catch/finally path.
Preserve the existing success behavior and browser branch.
- Around line 165-195: Serialize all profile file operations through one shared
queue: update persistProfileToFile and persistAllProfilesToFiles to enqueue work
after ready rather than attaching independent continuations. In
src/stores/deckProfile.ts lines 437-449, chain the undo saveProfiles operation
after deleteItemFiles completes. In lines 470-482, enqueue renameItemFiles and
persistItem together so concurrent persists cannot use the old basename.

In `@src/stores/misstore.ts`:
- Around line 740-754: Update the existing-plugin branch in installPlugin to
reuse updatePlugin’s confirmation and permission-expansion checks before
applying meta.permissions; if the source is unchanged, limit the operation to
linking the scope without silently updating permissions.

In `@src/stores/skills.ts`:
- Around line 385-398: Update initFileStorage so it captures memoryOnly skills
before the fileSkills.length === 0 early return, then appends those skills to
the seeded built-ins after seedBuiltIns completes. Preserve the existing
non-empty merge behavior and ensure skills added during initialization remain in
skills.value.

In `@src/stores/theme.ts`:
- Around line 467-488: Update renameTheme so it replaces the renamed theme
within installedThemes.value rather than mutating theme.name in place, ensuring
shallowRef consumers observe the change before setStorageJson and file
persistence. Preserve the existing theme lookup, rename synchronization, and
persistence flow.

In `@src/stores/themeFileSync.ts`:
- Around line 2-72: Move the theme codec and migration-related pure logic out of
the store layer into service modules: in src/stores/themeFileSync.ts lines 2-72,
relocate parsing, validation, ID injection, serialization, and the
createSingleFileCollection configuration to a service while preserving its
behavior; in src/stores/theme.ts lines 686-725, relocate memory/file
reconciliation and migration planning to a service. Keep both store locations
limited to applying service results alongside subscription, cache, UI state, and
file operations, and expose the extracted pure logic for direct unit testing.

---

Nitpick comments:
In `@src/capabilities/builtins/plugins.ts`:
- Line 467: Extract the repeated plugin history basename expression into a
shared historyBasename(plugin) helper, preserving the existing fileBase ?? (name
|| installId) precedence. Replace the derivation at all three sites in the
relevant plugin history/revert flows with calls to this helper so every
operation uses the same basename logic.

In `@src/components/deck/PluginCard.vue`:
- Around line 69-76: Extract the duplicated updateTitle logic into a shared
useStoreUpdateTitle composable, then replace the local computed in
src/components/deck/PluginCard.vue lines 69-76 and
src/components/deck/QueryCard.vue lines 65-72 with calls passing updatedAt and
version accessors. Ensure the composable verifies or converts the
registry-provided ISO updatedAt value to the input type expected by formatDate,
while preserving the existing tooltip text and empty-title behavior.

In `@src/components/window/PluginsContent.vue`:
- Around line 96-101: The external-file call sites use inconsistent missing-file
behavior. In src/components/window/PluginsContent.vue lines 96-101, update the
file descriptor logic to return the disabled descriptor with an empty name when
p.fileBase is unset instead of null. Keep the existing disabled forms unchanged
in src/components/window/SkillEditContent.vue lines 34-43,
src/components/window/ProfileEditorContent.vue lines 112-128, and
src/components/window/ThemeEditorContent.vue lines 73-83.

In `@src/services/singleFileCollection.test.ts`:
- Around line 444-549: migrateItems の移行テストに履歴サイドカー追随のケースを追加してください。特に copy-adopt
経路を検証するため、`Bad Name` の主ファイルと `Bad Name.history.json5` を用意して `migrateItems`
を実行し、新しい slug 側へ履歴内容が移送され、旧名側の主ファイルと履歴サイドカーが削除されることを確認してください。

In `@src/stores/columnQueries.test.ts`:
- Around line 54-58: Add a test suite for the store’s recordStoreBaseline method
alongside the applyStoreUpdate tests, verifying it records storeSha512 and
storeVersion while leaving src and updatedAt unchanged. Ensure the test covers
the baseline behavior used by recordQueryBaselines in misstore.ts.

In `@src/stores/columnQueries.ts`:
- Around line 272-294: Update applyStoreUpdate so undefined optional patch
fields do not overwrite existing NamedQueryMeta values when registry entries
omit them. Preserve prev.description and prev.iconUrl unless the patch provides
defined replacements, while continuing to apply other patch fields and persist
the resulting next value.

In `@src/stores/deckProfile.ts`:
- Around line 591-601: The merge predicate currently embedded in the store
should be extracted into a named pure function under src/services/, near the
existing deck profile file logic such as deckProfileFiles. Move the “matches by
id or by name and createdAt” filtering rule into that function, update the store
to call it, and add direct unit tests covering the matching and memory-only
cases.

In `@src/stores/deckProfileFileSync.test.ts`:
- Around line 229-245: The delete test in deckProfileFileSync.test.ts only
verifies file removal; extend it to capture the undo callback returned by
deleteProfile, invoke it immediately, and then wait for and assert that the
profile file exists again. Keep the existing setup and deletion assertion as
appropriate, and cover the saveProfiles/ready continuation outcome.

In `@src/stores/plugins.ts`:
- Around line 611-654: Update applyStoreUpdate so plugin.configData is
synchronized with patch.config: retain values for keys still defined, add
defaults for new keys, and remove entries whose configuration keys were removed.
Preserve existing behavior when patch.config is absent.

In `@src/stores/widgets.ts`:
- Around line 400-428: Update applyStoreUpdate so widget.iconUrl is changed only
when patch.iconUrl is defined, preserving the existing stored icon when the
registry omits it. Keep the remaining metadata assignments and persistence
behavior unchanged, matching installWidget’s conditional icon handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a9c6a43d-46ab-4c8c-a6d8-2b7483ede113

📥 Commits

Reviewing files that changed from the base of the PR and between 9babedc and ffcbec8.

⛔ Files ignored due to path filters (2)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (62)
  • package.json
  • src-tauri/Cargo.toml
  • src-tauri/openapi.json
  • src-tauri/src/commands/settings.rs
  • src-tauri/src/settings_store.rs
  • src-tauri/tauri.conf.json
  • src/bindings.ts
  • src/capabilities/builtins/plugins.ts
  • src/capabilities/builtins/skills.ts
  • src/capabilities/builtins/theme.ts
  • src/capabilities/builtins/widgets.ts
  • src/components/common/AppConfirm.vue
  • src/components/common/CodeDiffView.dom.test.ts
  • src/components/common/CodeDiffView.vue
  • src/components/deck/DeckPluginManagerColumn.vue
  • src/components/deck/DeckQueryManagerColumn.vue
  • src/components/deck/DeckSkillColumn.vue
  • src/components/deck/DeckThemeManagerColumn.vue
  • src/components/deck/DeckWidgetColumn.vue
  • src/components/deck/PluginCard.vue
  • src/components/deck/QueryCard.vue
  • src/components/deck/ThemeCard.vue
  • src/components/deck/WidgetCard.dom.test.ts
  • src/components/deck/WidgetCard.vue
  • src/components/window/BackupContent.vue
  • src/components/window/PluginsContent.vue
  • src/components/window/ProfileEditorContent.vue
  • src/components/window/SkillEditContent.vue
  • src/components/window/ThemeEditorContent.vue
  • src/services/deckProfileCodec.test.ts
  • src/services/deckProfileCodec.ts
  • src/services/deckProfileFiles.ts
  • src/services/idFreeze.test.ts
  • src/services/idFreeze.ts
  • src/services/settingsSlug.test.ts
  • src/services/settingsSlug.ts
  • src/services/sidecarFileCollection.test.ts
  • src/services/sidecarFileCollection.ts
  • src/services/singleFileCollection.test.ts
  • src/services/singleFileCollection.ts
  • src/stores/columnQueries.test.ts
  • src/stores/columnQueries.ts
  • src/stores/confirm.ts
  • src/stores/deck.ts
  • src/stores/deckProfile.ts
  • src/stores/deckProfileFileSync.test.ts
  • src/stores/misstore.test.ts
  • src/stores/misstore.ts
  • src/stores/plugins.test.ts
  • src/stores/plugins.ts
  • src/stores/skills.test.ts
  • src/stores/skills.ts
  • src/stores/skillsFileSync.test.ts
  • src/stores/theme.dom.test.ts
  • src/stores/theme.ts
  • src/stores/themeFileSync.ts
  • src/stores/widgets.test.ts
  • src/stores/widgets.ts
  • src/styles/global.css
  • src/theme/types.ts
  • src/utils/settingsFs.ts
  • src/utils/toastNotify.ts

Comment thread src-tauri/src/settings_store.rs
Comment thread src/components/common/CodeDiffView.vue
Comment on lines +218 to +221
/** 更新の主表示は updatedAt、version は補助 (#1040) */
function storeUpdateTitle(entry: StoreSkillEntry): string {
return `ストア更新日: ${formatDate(entry.updatedAt)} / v${entry.version}`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the updatedAt field declaration and the formatDate contract.
rg -nP -C4 'interface Store(Skill|Widget|Theme|Plugin|Query)Entry' src/stores/misstore.ts
rg -nP -C6 'export function formatDate' src/utils/format.ts

Repository: notedeck-dev/notedeck

Length of output: 1660


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- StoreSkillEntry declaration ---'
sed -n '165,205p' src/stores/misstore.ts

printf '%s\n' '--- DeckSkillColumn context ---'
sed -n '190,235p' src/components/deck/DeckSkillColumn.vue

printf '%s\n' '--- updatedAt references ---'
rg -n -C3 'updatedAt|StoreSkillEntry|storeUpdateTitle' src

printf '%s\n' '--- registry and API schema references ---'
rg -n -C3 'skill.*registry|registry.*skill|StoreSkill|updated_at|updatedAt' src

printf '%s\n' '--- read-only static verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

misstore = Path("src/stores/misstore.ts").read_text()
deck = Path("src/components/deck/DeckSkillColumn.vue").read_text()

match = re.search(
    r"export interface StoreSkillEntry\s*\{(?P<body>.*?)\n\}",
    misstore,
    re.S,
)
if not match:
    raise SystemExit("StoreSkillEntry declaration not found")

body = match.group("body")
field = re.search(r"^\s*updatedAt(\?)?\s*:\s*([^/\n]+)", body, re.M)
print("StoreSkillEntry.updatedAt declaration:", field.group(0).strip() if field else "ABSENT")
print("StoreSkillEntry.updatedAt optional:", bool(field and field.group(1)))

calls = re.findall(r"formatDate\(([^)]*)\)", deck)
print("formatDate arguments in DeckSkillColumn.vue:", calls)

fmt = Path("src/utils/format.ts").read_text()
print("formatDate has falsy-input guard:", bool(re.search(r"if\s*\(\s*!iso\s*\)\s*return\s+['\"]['\"]", fmt)))
PY

Repository: notedeck-dev/notedeck

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- misstore registry fetch and skill transformation ---'
sed -n '225,390p' src/stores/misstore.ts
sed -n '470,555p' src/stores/misstore.ts

printf '%s\n' '--- skill registry files ---'
git ls-files | rg -i '(^|/)(registry|registries|misstore)(/|$)|skill.*\.(json|json5|ts)$|registry.*\.(json|json5|ts)$' | head -120

printf '%s\n' '--- bounded registry-related references ---'
rg -n -m 80 -C2 'skillsUrl|fetchSkills|skillEntries|StoreSkillEntry|updatedAt' src/stores/misstore.ts src/components/deck/DeckSkillColumn.vue scripts package.json README.md 2>/dev/null || true

printf '%s\n' '--- read-only boundary verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

text = Path("src/stores/misstore.ts").read_text()
skill = re.search(r"export interface StoreSkillEntry\s*\{(?P<body>.*?)\n\}", text, re.S)
assert skill, "StoreSkillEntry not found"
body = skill.group("body")
print("updatedAt optional in StoreSkillEntry:", bool(re.search(r"^\s*updatedAt\?", body, re.M)))
print("updatedAt required in StoreSkillEntry:", bool(re.search(r"^\s*updatedAt\s*:", body, re.M)))

for m in re.finditer(r"(skillEntries|fetchSkills|skillsUrl|StoreSkillEntry)", text):
    line = text.count("\n", 0, m.start()) + 1
    print(f"{line}: {text.splitlines()[line-1].strip()}")
PY

Repository: notedeck-dev/notedeck

Length of output: 19099


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- registry endpoint declarations ---'
sed -n '1,55p' src/stores/misstore.ts
sed -n '448,468p' src/stores/misstore.ts

printf '%s\n' '--- date behavior probe ---'
node - <<'JS'
function formatDate(iso) {
  if (!iso) return ''
  return new Date(iso).toLocaleDateString()
}
for (const value of [undefined, null, '', 'not-a-date', '2026-01-01T00:00:00Z']) {
  let output
  try {
    output = formatDate(value)
  } catch (error) {
    output = `${error.name}: ${error.message}`
  }
  console.log(JSON.stringify({ value, output }))
}
JS

Repository: notedeck-dev/notedeck

Length of output: 2932


Validate registry entries before assigning them to StoreSkillEntry[].

fetchSkills() trusts res.json().skills without runtime validation. Missing updatedAt produces a blank date, and an invalid value produces Invalid Date. Reject or normalize such entries before storeUpdateTitle() uses them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/deck/DeckSkillColumn.vue` around lines 218 - 221, Validate or
normalize each entry returned by fetchSkills() before assigning it to
StoreSkillEntry[]. Ensure updatedAt is present and represents a valid date,
rejecting or safely normalizing invalid registry entries so storeUpdateTitle()
never receives missing or invalid date data.

Comment thread src/components/deck/PluginCard.vue
Comment thread src/components/deck/ThemeCard.vue
Comment thread src/stores/deckProfile.ts
Comment on lines 571 to 583
// Kick off async file sync in background (Tauri only)
if (settingsFs.isTauri) {
initFileStorage().catch((e) =>
console.warn('[deckProfile] file storage init failed:', e),
)
initFileStorage()
.catch((e) =>
console.warn('[deckProfile] file storage init failed:', e),
)
.finally(() => resolveReady?.())
} else {
initialized.value = true
flushConsoleMigrationNotice()
resolveReady?.()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Set initialized when initFileStorage rejects.

initialized.value = true runs only at the end of initFileStorage (line 646). ensureDefaults catches a rejection and resolves ready in .finally, but it never sets initialized.

If profileFiles.loadAll() throws (corrupt directory, permission error, IPC failure), initialized stays false for the whole session in Tauri. Any consumer that gates rendering or actions on initialized then waits forever. The browser branch already sets it unconditionally.

🛡️ Proposed fix
     if (settingsFs.isTauri) {
       initFileStorage()
         .catch((e) =>
           console.warn('[deckProfile] file storage init failed:', e),
         )
-        .finally(() => resolveReady?.())
+        .finally(() => {
+          initialized.value = true
+          resolveReady?.()
+        })
     } else {

Also applies to: 644-647

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/deckProfile.ts` around lines 571 - 583, Update the Tauri
initialization flow in ensureDefaults and initFileStorage so initialized.value
is set to true even when initFileStorage rejects, before resolving readiness in
the catch/finally path. Preserve the existing success behavior and browser
branch.

Comment thread src/stores/misstore.ts
Comment thread src/stores/skills.ts
Comment on lines 385 to +398
async function initFileStorage(): Promise<void> {
const files = await settingsFs.listSkillFiles()
const fileSkills: SkillMeta[] = []
for (const filename of files) {
try {
const raw = await settingsFs.readSkillFile(filename)
const { meta, body } = parseSkillFile(raw)
const fallbackId = filename.replace(/\.md$/, '')
fileSkills.push(
metaFromFrontmatter(meta as SkillFrontmatter, body, fallbackId),
)
} catch (e) {
console.warn(`[skills] failed to parse ${filename}:`, e)
}
}
const { items: fileSkills } = await skillFiles.loadAll()
fileSkills.sort((a, b) => a.createdAt - b.createdAt)

if (fileSkills.length === 0) {
await seedBuiltIns()
initialized.value = true
} else {
skills.value = fileSkills
await migrateLegacyAizu()
await migrateStoreMovedBuiltIns()
await seedMissingBuiltIns()
// initialized=true を立てた **後** に sync する。update() 内の persist は
// `if (initialized.value)` ガード越しなので、立てる前に呼ぶと in-memory
// だけ反映され disk に書かれず、次回起動も古い frontmatter を読んでしまう。
initialized.value = true
const templates = await loadBuiltInTemplates()
syncBuiltInsMetadata(templates)
return
}

// 初期化 (この async 関数が走る間) にメモリ追加された skill は残す
const fileIds = new Set(fileSkills.map((s) => s.id))
const memoryOnly = skills.value.filter((s) => !fileIds.has(s.id))
skills.value = [...fileSkills, ...memoryOnly]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Memory-only skills are dropped when the skills directory is empty.

If fileSkills.length === 0, the function calls seedBuiltIns() and returns. seedBuiltIns() assigns skills.value = seeded, so any skill added through add() while initFileStorage() was awaiting is removed from memory and never persisted. The non-empty branch below handles this case with memoryOnly, and src/stores/widgets.ts handles it too. Consider computing memoryOnly before the empty check and appending it after seeding.

🛠️ Proposed fix
   async function initFileStorage(): Promise<void> {
     const { items: fileSkills } = await skillFiles.loadAll()
     fileSkills.sort((a, b) => a.createdAt - b.createdAt)
 
+    // 初期化中にメモリ追加された skill は残す
+    const fileIds = new Set(fileSkills.map((s) => s.id))
+    const memoryOnly = skills.value.filter((s) => !fileIds.has(s.id))
+
     if (fileSkills.length === 0) {
       await seedBuiltIns()
+      if (memoryOnly.length > 0) {
+        skills.value = [...skills.value, ...memoryOnly]
+        await Promise.all(memoryOnly.map((s) => persist(s)))
+      }
       initialized.value = true
       return
     }
 
-    // 初期化 (この async 関数が走る間) にメモリ追加された skill は残す
-    const fileIds = new Set(fileSkills.map((s) => s.id))
-    const memoryOnly = skills.value.filter((s) => !fileIds.has(s.id))
     skills.value = [...fileSkills, ...memoryOnly]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function initFileStorage(): Promise<void> {
const files = await settingsFs.listSkillFiles()
const fileSkills: SkillMeta[] = []
for (const filename of files) {
try {
const raw = await settingsFs.readSkillFile(filename)
const { meta, body } = parseSkillFile(raw)
const fallbackId = filename.replace(/\.md$/, '')
fileSkills.push(
metaFromFrontmatter(meta as SkillFrontmatter, body, fallbackId),
)
} catch (e) {
console.warn(`[skills] failed to parse ${filename}:`, e)
}
}
const { items: fileSkills } = await skillFiles.loadAll()
fileSkills.sort((a, b) => a.createdAt - b.createdAt)
if (fileSkills.length === 0) {
await seedBuiltIns()
initialized.value = true
} else {
skills.value = fileSkills
await migrateLegacyAizu()
await migrateStoreMovedBuiltIns()
await seedMissingBuiltIns()
// initialized=true を立てた **後** に sync する。update() 内の persist は
// `if (initialized.value)` ガード越しなので、立てる前に呼ぶと in-memory
// だけ反映され disk に書かれず、次回起動も古い frontmatter を読んでしまう。
initialized.value = true
const templates = await loadBuiltInTemplates()
syncBuiltInsMetadata(templates)
return
}
// 初期化 (この async 関数が走る間) にメモリ追加された skill は残す
const fileIds = new Set(fileSkills.map((s) => s.id))
const memoryOnly = skills.value.filter((s) => !fileIds.has(s.id))
skills.value = [...fileSkills, ...memoryOnly]
async function initFileStorage(): Promise<void> {
const { items: fileSkills } = await skillFiles.loadAll()
fileSkills.sort((a, b) => a.createdAt - b.createdAt)
// 初期化中にメモリ追加された skill は残す
const fileIds = new Set(fileSkills.map((s) => s.id))
const memoryOnly = skills.value.filter((s) => !fileIds.has(s.id))
if (fileSkills.length === 0) {
await seedBuiltIns()
if (memoryOnly.length > 0) {
skills.value = [...skills.value, ...memoryOnly]
await Promise.all(memoryOnly.map((s) => persist(s)))
}
initialized.value = true
return
}
skills.value = [...fileSkills, ...memoryOnly]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/skills.ts` around lines 385 - 398, Update initFileStorage so it
captures memoryOnly skills before the fileSkills.length === 0 early return, then
appends those skills to the seeded built-ins after seedBuiltIns completes.
Preserve the existing non-empty merge behavior and ensure skills added during
initialization remain in skills.value.

Comment thread src/stores/theme.ts
Comment on lines 467 to +488
function renameTheme(themeId: string, newName: string): void {
const theme = installedThemes.value.find((t) => t.id === themeId)
if (!theme) return

const oldFilename = settingsFs.themeFilename(theme.name || theme.id)
theme.name = newName
const newFilename = settingsFs.themeFilename(newName)

setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)

if (initialized.value && oldFilename !== newFilename) {
// Delete old file and write new one (theme id stays the same, only name changes)
Promise.all([
settingsFs.deleteTheme(oldFilename),
themeFileSync.persistSingleTheme(theme),
]).catch((e) => console.warn('[theme] failed to rename theme file:', e))
}
if (!settingsFs.isTauri) return
// ファイルは rename コマンドで追随させる (ID 不変・主ファイル + 履歴。
// 旧削除 + 新書込の並行発火は旧ファイルを孤児化させるため禁止 #913)。
// rename の完了を待ってから保存する
void ready
.then(async () => {
adoptMirrorFileBase(theme)
await themeFileSync.themeFiles.renameItemFiles(
theme,
installedThemes.value,
)
await themeFileSync.themeFiles.persistItem(theme, installedThemes.value)
setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)
})
.catch((e) => console.warn('[theme] failed to rename theme file:', e))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Replace the renamed theme object before persistence.

Line 471 mutates an item inside installedThemes, which is a shallowRef. Vue does not notify consumers of this nested mutation. The UI can retain the old theme name until another list replacement occurs.

Replace the item in installedThemes.value before writing the mirror and files.

Proposed fix
-    theme.name = newName
+    const renamed: MisskeyTheme = { ...theme, name: newName }
+    installedThemes.value = installedThemes.value.map((item) =>
+      item.id === themeId ? renamed : item,
+    )
     setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)
 
     if (!settingsFs.isTauri) return
     void ready
       .then(async () => {
-        adoptMirrorFileBase(theme)
+        adoptMirrorFileBase(renamed)
         await themeFileSync.themeFiles.renameItemFiles(
-          theme,
+          renamed,
           installedThemes.value,
         )
-        await themeFileSync.themeFiles.persistItem(theme, installedThemes.value)
+        await themeFileSync.themeFiles.persistItem(
+          renamed,
+          installedThemes.value,
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function renameTheme(themeId: string, newName: string): void {
const theme = installedThemes.value.find((t) => t.id === themeId)
if (!theme) return
const oldFilename = settingsFs.themeFilename(theme.name || theme.id)
theme.name = newName
const newFilename = settingsFs.themeFilename(newName)
setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)
if (initialized.value && oldFilename !== newFilename) {
// Delete old file and write new one (theme id stays the same, only name changes)
Promise.all([
settingsFs.deleteTheme(oldFilename),
themeFileSync.persistSingleTheme(theme),
]).catch((e) => console.warn('[theme] failed to rename theme file:', e))
}
if (!settingsFs.isTauri) return
// ファイルは rename コマンドで追随させる (ID 不変・主ファイル + 履歴。
// 旧削除 + 新書込の並行発火は旧ファイルを孤児化させるため禁止 #913)。
// rename の完了を待ってから保存する
void ready
.then(async () => {
adoptMirrorFileBase(theme)
await themeFileSync.themeFiles.renameItemFiles(
theme,
installedThemes.value,
)
await themeFileSync.themeFiles.persistItem(theme, installedThemes.value)
setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)
})
.catch((e) => console.warn('[theme] failed to rename theme file:', e))
function renameTheme(themeId: string, newName: string): void {
const theme = installedThemes.value.find((t) => t.id === themeId)
if (!theme) return
const renamed: MisskeyTheme = { ...theme, name: newName }
installedThemes.value = installedThemes.value.map((item) =>
item.id === themeId ? renamed : item,
)
setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)
if (!settingsFs.isTauri) return
// ファイルは rename コマンドで追随させる (ID 不変・主ファイル + 履歴。
// 旧削除 + 新書込の並行発火は旧ファイルを孤児化させるため禁止 #913)。
// rename の完了を待ってから保存する
void ready
.then(async () => {
adoptMirrorFileBase(renamed)
await themeFileSync.themeFiles.renameItemFiles(
renamed,
installedThemes.value,
)
await themeFileSync.themeFiles.persistItem(
renamed,
installedThemes.value,
)
setStorageJson(STORAGE_KEYS.themeInstalledThemes, installedThemes.value)
})
.catch((e) => console.warn('[theme] failed to rename theme file:', e))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/theme.ts` around lines 467 - 488, Update renameTheme so it
replaces the renamed theme within installedThemes.value rather than mutating
theme.name in place, ensuring shallowRef consumers observe the change before
setStorageJson and file persistence. Preserve the existing theme lookup, rename
synchronization, and persistence flow.

Comment on lines +2 to +72
import { injectJson5Id } from '@/services/idFreeze'
import { createSingleFileCollection } from '@/services/singleFileCollection'
import type { MisskeyTheme, NotedeckThemeMeta } from '@/theme/types'
import * as settingsFs from '@/utils/settingsFs'
import { notifyWarningToast } from '@/utils/toastNotify'

/**
* テーマ (`themes/<base>.ndtheme.json5` 単一ファイル) の永続化
* (#913 で ID → ファイル名対応表化)。
*
* - 対応表の実体は theme オブジェクトの runtime-only な `fileBase`。
* ファイルへは書かない (serializeTheme が projection で strip する)
* - ID 凍結の実効値 = `custom-` + 完全ファイル名 (現行フォールバックと同値)
* - themes/ の素の `.json5` (規定拡張子でないもの) は従来どおり無視
* (drop-in は #1041 スコープ外)
*/

type ParsedTheme = Record<string, unknown>

/** テーマ 1 件のファイル projection。runtime-only の fileBase は含めない。 */
function serializeTheme(theme: MisskeyTheme): string {
const out: Record<string, unknown> = {
id: theme.id,
name: theme.name,
base: theme.base === 'light' ? 'light' : 'dark',
props: theme.props,
}
// NoteDeck 独自メタ ($notedeck) は従来どおりファイルに書く
if (theme.$notedeck) out.$notedeck = theme.$notedeck
return JSON5.stringify(out, null, 2)
}

export const themeFiles = createSingleFileCollection<MisskeyTheme, ParsedTheme>(
{
logTag: 'theme',
notify: notifyWarningToast,
kindFallback: 'theme',
ext: settingsFs.THEME_EXT,
// 占有判定・sweep には .history.json5 を含む実列挙が要る
// (規定拡張子の filter はコレクション側が行う)
list: () => settingsFs.listThemeDirFiles(),
read: (filename) => settingsFs.readTheme(filename),
write: (filename, content) => settingsFs.writeTheme(filename, content),
remove: (filename) => settingsFs.deleteTheme(filename),
rename: (oldFilename, newFilename) =>
settingsFs.renameTheme(oldFilename, newFilename),
parse: (raw) => JSON5.parse(raw) as ParsedTheme,
accepts: (p) => !!p && typeof p === 'object' && !!p.props,
rawIdOf: (p) => p.id,
effectiveIdOf: (filename) => `custom-${filename}`,
injectId: (raw, id) => injectJson5Id(raw, 'id', id),
fromFile: (p, id, filename) => {
const theme: MisskeyTheme = {
id,
name: typeof p.name === 'string' && p.name ? p.name : filename,
base: p.base === 'light' ? 'light' : 'dark',
props: p.props as Record<string, string>,
}
// NoteDeck 独自メタ ($notedeck.storeId / installedFor 等) を保持
// しないと再起動時にストア紐付き / per-account 紐付きが消える
if (p.$notedeck && typeof p.$notedeck === 'object') {
theme.$notedeck = { ...(p.$notedeck as NotedeckThemeMeta) }
}
return theme
},
displayNameOf: (p) => (typeof p.name === 'string' ? p.name : ''),
idOf: (t) => t.id,
nameOf: (t) => t.name,
serialize: serializeTheme,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move theme codec and migration rules to src/services/.

The store layer now owns file codec, reconciliation, and migration rules. Keep stores limited to subscription, cache, and UI state. Test the extracted pure logic directly.

  • src/stores/themeFileSync.ts#L2-L72: move theme parsing, validation, ID injection, and serialization configuration into a service module.
  • src/stores/theme.ts#L686-L725: move memory/file reconciliation and migration planning into a service module, then let the store apply the resulting state and file operations.

As per coding guidelines: “正規化、マイグレーション、マージ規則、ファイル codec などの純ロジックは store に置かず src/services/ に実装し、直接ユニットテストする”.

📍 Affects 2 files
  • src/stores/themeFileSync.ts#L2-L72 (this comment)
  • src/stores/theme.ts#L686-L725
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/themeFileSync.ts` around lines 2 - 72, Move the theme codec and
migration-related pure logic out of the store layer into service modules: in
src/stores/themeFileSync.ts lines 2-72, relocate parsing, validation, ID
injection, serialization, and the createSingleFileCollection configuration to a
service while preserving its behavior; in src/stores/theme.ts lines 686-725,
relocate memory/file reconciliation and migration planning to a service. Keep
both store locations limited to applying service results alongside subscription,
cache, UI state, and file operations, and expose the extracted pure logic for
direct unit testing.

Source: Coding guidelines

hitalin and others added 7 commits August 12, 2026 11:21
開きデリミタ長を `---\n` (4) 決め打ちにしていたため、CRLF (`---\r\n`) の
skill ファイルへ id を凍結すると挿入位置が 1 バイトずれ、id 値と直前の行が
両方壊れていた (`id: 'tenki'l` / `mode: manua`)。Windows の外部エディタで
保存したファイルが対象になる。

開きデリミタをマッチから取り出して長さを導く形に変更し、CRLF の回帰テストを
追加した。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
同じ storeId のプラグインが既にある状態で別スコープへインストールすると、
installPlugin の既存分岐が permissions を確認なしで上書きしていた。
updatePlugin 側は #1040 で「権限拡大は再同意」を実装済みで、この経路だけが
抜け穴になっていた。

確認 + 適用を confirmPluginUpdate に切り出して両経路で共用する。
インストール経路は sha 一致 (中身が同じ) ならスコープのリンクのみ行い本体と
権限に触れず、権限が拡大するときだけ再同意を取る。断られた場合も
「このスコープへ入れる」操作自体は成立させ、中身は既存のまま据え置く。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#913 の不変条件 (ID → 実ファイル名の対応表が唯一の正) を壊す 2 経路を直した。

- renameFileSet: 主ファイルの rename 成功後に履歴サイドカーの rename が
  失敗すると例外が上位へ飛び、fileBase が旧名のまま残って実ファイルとずれて
  いた。履歴の失敗は警告のみで飲む。case-only の 2 段リネームも、中間名へ
  移った時点で fileBase を確定させ 2 段目の失敗で取り残されないようにした
  (singleFileCollection / sidecarFileCollection の両方)
- import_group: グループの書込中に失敗すると create_new で予約済みの空
  ファイルが解放されないまま error が上位へ飛び、次回 import の衝突判定を
  汚していた。失敗時はそのグループの予約を全て解放してから伝播する

あわせて、移行後に旧 basename の履歴を新 basename へ移送しない (sweep で
削除する) という #913 の決定を回帰テストで固定した。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- PluginCard: 更新ボタンが capabilityOk を見ておらず、非対応バージョンへ
  更新できていた。インストールボタンと同じガードに揃える
- ThemeCard: 更新ボタンに disabled が無く連打で並行更新が走っていた
- CodeDiffView: エディタ面とたたまれた行の色が直書きだったので
  --nd-codeEditorBg / --nd-codeEditorFgMuted を global.css に定義して参照する
  (カスタム CSS から上書き可能。color-mix の挙動は据え置き)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sidebar な widget カラムでは removeWidget が widgetsStore.removeWidget を
呼んでおり、確認なし・undo なしで widget のコードがファイルごと消えていた。
通常カラムでは同じ操作が「配置から外す」だけなので UI から区別できない。

attachWidget が sidebar 並びを「もう 1 つの配置先」として扱っている以上、
外す操作だけが本体を消すのは非対称なので、本体削除ではなく
removeFromSidebar による配置解除に揃えた。本体削除はライブラリピッカー
(確認ダイアログ + undo トースト) に一本化する。

あわせて removeWidget が元の位置へ戻す復元関数を返すようにし、外す操作にも
undo トーストを出す。ボタンのアイコンも破壊的操作と区別できるよう
ti-x から ti-circle-minus に変更した。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ゴミ箱 (ti-trash) は本体削除 (コード / 本文 / テーマも消える) 専用にし、
可逆な「配置・スコープから外す」は ti-circle-minus + 中立色に揃えた。
プラグイン / ウィジェット / テーマ / スキル / クエリの 5 種別で同じ意匠・
同じ語彙 (「〜から外す」/「ライブラリから削除 (…も消えます)」) を使う。

- プラグインのスコープ解除に undo トースト (linkScope で戻す) を足す。
  可逆操作なので確認ダイアログは足さない (#747 の方針)
- PluginCard の confirmingUninstall は呼び出し側から渡されておらず
  2 段階確認として機能していなかったので、prop・スタイル・分岐ごと削除。
  emit も意味に合わせて uninstall → detach にリネーム
- テーマの per-account カラムは紐付けが自分だけなら本体ごと消えるので、
  そのときだけゴミ箱表示に落として実際の動作とアイコンを一致させる

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
アクティブプロファイルの実体は localStorage (STORAGE_KEYS.deckActiveProfile)
にあり、settings.json5 には一度も書かれていなかった。宣言だけが残っていて
「ここに載っている」と読めてしまうので消す。

parseSettings は未知キーを forward-compat で保持するため、既存の
settings.json5 に値が入っていても壊れない (無視されてそのまま残る)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the 📖Doc Documentation related issue/PR label Aug 12, 2026
@hitalin
hitalin merged commit dfcba46 into main Aug 12, 2026
21 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📖Doc Documentation related issue/PR javascript Pull requests that update javascript code rust Pull requests that update rust code

Projects

None yet

1 participant