Skip to content

feat: per-skill deletion protection#226

Merged
ryota-murakami merged 6 commits into
mainfrom
feat/skill-deletion-protection
Jun 16, 2026
Merged

feat: per-skill deletion protection#226
ryota-murakami merged 6 commits into
mainfrom
feat/skill-deletion-protection

Conversation

@ryota-murakami

@ryota-murakami ryota-murakami commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds per-skill deletion protection so users can lock individual skills against accidental deletion. Protected skills are excluded from both single-item and bulk delete flows at every layer — Redux state, business logic, and UI.

Changes

State (protectSlice)

  • New Redux slice with SkillName[] state persisted via redux-persist
  • Selectors: selectProtectedNamesSet (memoized ReadonlySet), selectIsProtected
  • Redux migration initializes protect: [] for existing persisted states

Business logic (partitionGlobalDeleteTargets)

  • New optional protectedNames param (defaults to empty set)
  • Protected skills short-circuit before orphan/stale path resolution into a protectedErrors bucket with code: 'EPROTECTED'

UI

  • ProtectButton extracted from SkillItem: Lock/LockOpen icons with aria-label="Lock {name}" / "Unlock {name}" and tooltip
  • Delete button suppressed for locked skills; layout position keyed on showDeleteButtonBase so locking never shifts adjacent controls
  • getCardContentPaddingClass updated to pr-36 for 3-overlay rows (lock + bookmark + X)
  • Bulk delete dialog: early exit with dedicated copy when all selected are protected; otherwise appends "N protected skills will be skipped."
  • MainContent.tsx excludes protectedErrors from deleteItems so protected rows never flash red or enter retry selection

Docs

  • DESIGN.md: amber semantic cap rule, toggleable protection control states (opacity-40/70/100), tooltip requirement for toggleable controls
  • TODOS.md: P2/P3 follow-up items

Summary by CodeRabbit

新機能

  • スキルの保護機能を追加:各スキル行に Lock/Unlock ボタンを表示し、保護中は削除をできないようにしました
  • 一括削除では、保護されたスキルは自動的にスキップされます
  • ロック状態は再起動後も保持されます

変更・改善

  • 一括削除の説明文に「保護スキップ件数」を反映し、全件保護時は CTA を無効化して理由を表示します
  • 保護状態のツールチップ文言と状態遷移表示(Rest/Hover/Active)を調整しました

Add a new `protectSlice` (SkillName[]) persisted via redux-persist.
Wire it into the root reducer and create a migration that initialises the
new key with an empty array in existing persisted states.

Exports: addProtection, removeProtection, selectProtectedNamesSet
(memoised ReadonlySet), selectIsProtected (plain predicate).
Add optional `protectedNames: ReadonlySet<Skill['name']>` third param
(defaults to empty set). Protected skills are intercepted before the
orphan / stale paths and returned in a new `protectedErrors` bucket with
`outcome: 'error'` / `code: 'EPROTECTED'`.

This lets callers distinguish intentional skips from genuine failures.
SkillItem:
- Extract ProtectButton sub-component (Lock/LockOpen icons, aria-labels)
- Suppress the delete X button when a skill is locked (isProtected gate)
- ProtectButton position uses showDeleteButtonBase (not post-gate value)
  so locking a skill does not shift the button's right-position class

skillItemHelpers:
- getCardContentPaddingClass now accepts showProtect to count 3-overlay
  rows correctly (pr-36 when lock + bookmark + X all coexist)

bulkDeleteCopy:
- Add early-exit guard: if all selected skills are protected (trashCount=0
  and all other counts=0), return 'All selected skills are protected and
  cannot be deleted.' before the ts-pattern match
- Append 'N protected skills will be skipped.' sentence when protectedCount > 0

MainContent:
- Pass protectedNames (selectProtectedNamesSet) to partitionGlobalDeleteTargets
- Exclude protectedErrors from deleteItems: they are intentional skips, not
  failures — keeping them inflated formatCascadeSummary denominators and
  caused protected rows to flash red and re-enter the retry selection set
DESIGN.md:
- Amber semantic cap (destructive only for delete/unlink, never lock)
- Toggleable Protection Controls: opacity-40/70/100 progression, tooltip req
- Bulk Destructive Dialogs with Skipped Items: skip count as secondary copy,
  all-protected → disable CTA

TODOS.md: add P2/P3 follow-up items for the protection feature
@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
skills-desktop Ready Ready Preview, Comment Jun 16, 2026 2:58pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

Walkthrough

新規 Redux スライス protectSlice を追加してスキル名のロック状態を管理し、SkillItemProtectButton を追加。partitionGlobalDeleteTargetsprotectedNames チェックを組み込み protectedErrors を返すよう変更。バルク削除確認ダイアログのコピーと MainContent のフローへ統合し、ストア永続化・マイグレーション対応も行った。

Changes

スキル保護機能の追加

Layer / File(s) Summary
protectSlice の新規実装とストア登録
src/renderer/src/redux/slices/protectSlice.ts, src/renderer/src/redux/slices/protectSlice.test.ts, src/renderer/src/redux/store.ts, src/renderer/src/redux/store.test.ts, src/renderer/src/redux/migrations.ts
ProtectStateaddProtectionremoveProtectionselectProtectedNamesSetselectIsProtected を新設。rootReducercreateStorageMiddlewareprotect を登録し、MigratableStateprotect?: unknown を追加。
削除パーティション関数と UI 型への protectedErrors 追加
src/renderer/src/components/skills/reviewedDestructiveTargets.ts, src/renderer/src/components/skills/reviewedDestructiveTargets.test.ts, src/renderer/src/redux/slices/uiSlice.ts, src/renderer/src/redux/slices/uiSlice.test.ts
PartitionedGlobalDeleteTargetsprotectedErrors フィールドを追加。partitionGlobalDeleteTargets が第3引数 protectedNames で先行チェックし EPROTECTED をルーティング。BulkConfirmState の delete バリアントにも protectedErrors を追加。
バルク削除ダイアログコピーへの protectedCount 統合
src/renderer/src/components/skills/bulkDeleteCopy.tsx, src/renderer/src/components/skills/bulkDeleteCopy.test.ts
renderBulkDeleteDescriptionprotectedCount?: number を追加。全件保護時は「All selected skills are protected and cannot be deleted.」、一部保護時はスキップ数サフィックスを出力する分岐を実装。
SkillItem への ProtectButton 追加と削除ゲート
src/renderer/src/components/skills/SkillItem.tsx, src/renderer/src/components/skills/SkillItem.browser.test.tsx, src/renderer/src/components/skills/skillItemHelpers.ts, src/renderer/src/components/skills/skillItemHelpers.test.ts
ProtectButton コンポーネント(Lock/LockOpen アイコン・ツールチップ)を新設。selectIsProtected で保護状態を取得し、showDeleteButton!isProtected でゲート。getCardContentPaddingClassshowProtect フラグを追加、pr-36 まで対応。オーバーレイ幅計算テスト拡充。
MainContent バルク削除フローへの保護スキップ統合
src/renderer/src/components/layout/MainContent.tsx, src/renderer/src/components/layout/MainContent.browser.test.tsx, src/renderer/src/components/layout/MainContent.selectionToolbar.browser.test.tsx, src/renderer/src/components/skills/SkillsList.browser.test.tsx
selectProtectedNamesSet を購読して partitionGlobalDeleteTargets 第3引数に渡す。getProtectedSkippedCount ヘルパーで保護スキップ数を算出し renderBulkDeleteDescriptionprotectedCount に供給。setBulkConfirm payload に protectedErrors を格納。テストストアに protect reducer を追加。
DESIGN.md / TODOS.md ガイドライン追記
DESIGN.md, TODOS.md
Amber カラー制限(2色のみ)・Toggleable Protection Controls 状態遷移仕様(Rest/Hover/Active opacity・ツールチップ)・Bulk Destructive Dialogs スキップ表示要件を追記。スキル名リネーム時ロック解除・isLocal 自動保護・localStorage 書き込み失敗警告の TODO を記録。

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SkillItem
  participant protectSlice
  participant partitionGlobalDeleteTargets
  participant MainContent
  participant renderBulkDeleteDescription

  User->>SkillItem: Lock ボタンクリック
  SkillItem->>protectSlice: dispatch(addProtection(name))
  protectSlice-->>SkillItem: selectIsProtected → true (Delete ボタン非表示)

  User->>MainContent: バルク削除 Primary Action
  MainContent->>protectSlice: selectProtectedNamesSet(state)
  protectSlice-->>MainContent: protectedNamesSet
  MainContent->>partitionGlobalDeleteTargets: skills, skillNames, protectedNamesSet
  partitionGlobalDeleteTargets-->>MainContent: { deleteTargets, protectedErrors, ... }
  MainContent->>MainContent: dispatch(setBulkConfirm({ protectedErrors, ... }))
  MainContent->>renderBulkDeleteDescription: protectedCount = protectedErrors.length
  renderBulkDeleteDescription-->>User: ダイアログに「N protected skills will be skipped」表示
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • laststance/skills-desktop#185: 本 PR が変更する reviewedDestructiveTargets の削除パーティションロジックが元々導入されたのと同一ファイルであり、protectedErrors の追加はその直接の延長線上にある。
  • laststance/skills-desktop#175: 本 PR が拡張する renderBulkDeleteDescription 関数の同一契約面で、sourceSummary 関連の文言生成との重複がある。
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main change: introducing per-skill deletion protection. It is specific, concise, and directly reflects the primary feature added.
Docstring Coverage ✅ Passed Docstring coverage is 91.67% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/skill-deletion-protection

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

@codecov-commenter

codecov-commenter commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.67925% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.84%. Comparing base (36bc989) to head (0726f13).

Files with missing lines Patch % Lines
src/renderer/src/components/skills/SkillItem.tsx 76.19% 3 Missing and 2 partials ⚠️
src/renderer/src/components/layout/MainContent.tsx 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #226      +/-   ##
==========================================
- Coverage   95.91%   95.84%   -0.08%     
==========================================
  Files         184      185       +1     
  Lines        5580     5627      +47     
  Branches     1253     1272      +19     
==========================================
+ Hits         5352     5393      +41     
- Misses          1        4       +3     
- Partials      227      230       +3     
Flag Coverage Δ
unittests 95.84% <88.67%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/renderer/src/components/skills/bulkDeleteCopy.tsx (1)

76-92: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

保護スキル混在時の削除説明件数が誤表示になります

Line 76-92 の hasBaseTrashCopyprotectedCount を見ていないため、trashCount < totalCount(例: 一部が protected)でも「全件を trash に移動」と表示されます。破壊的操作の確認文言として誤解を招くため、trashCount ベースの文言分岐に入るようにしてください。

差分案
-  const hasBaseTrashCopy =
-    orphanCleanupCount === 0 &&
-    orphanRescanCount === 0 &&
-    staleDeleteCount === 0
+  const hasBaseTrashCopy =
+    orphanCleanupCount === 0 &&
+    orphanRescanCount === 0 &&
+    staleDeleteCount === 0 &&
+    protectedCount === 0

Also applies to: 123-127

🤖 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/renderer/src/components/skills/bulkDeleteCopy.tsx` around lines 76 - 92,
The hasBaseTrashCopy condition does not account for protected skills, so when
some skills are protected (protectedCount > 0), the copy still displays "moves
all skills to trash" even though not all items can be moved due to the
protection. Add a check for protectedCount === 0 to the hasBaseTrashCopy
condition alongside the existing checks for orphanCleanupCount,
orphanRescanCount, and staleDeleteCount. This ensures the "base trash copy"
message only shows when there are genuinely no protected items. The same fix
should be applied at the other affected site (around line 123-127) where similar
logic handles protected item scenarios.
🤖 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/renderer/src/components/skills/SkillItem.tsx`:
- Around line 475-486: The handleDeleteClick function passes an empty Set() as
the protected skills parameter to partitionGlobalDeleteTargets, relying solely
on UI gating (the delete button being hidden for protected skills) to prevent
deletion of protected skills. This is fragile—if the UI gate assumption breaks,
protected skills can flow into deleteTargets. Pass the actual set of protected
skills to partitionGlobalDeleteTargets instead of an empty set at the call site
around line 485. Also apply the same fix at the other location around line
498-499 where partitionGlobalDeleteTargets is called with an empty protected
set. This ensures the business logic layer itself enforces protection exclusion
regardless of UI state.
- Around line 699-703: The X button existence check is duplicated across the
component, causing a padding mismatch. Line 702 in the ProtectButton component
uses showDeleteButtonBase to determine if the X button should be shown, while
the padding calculation around line 755 uses showDeleteButton which returns
false when protected. This inconsistency causes incorrect right padding (pr-24)
when the component is protected, leading to overlay text overlap. Unify the X
button existence judgment by using a single variable consistently across both
the ProtectButton hasXButton prop and the padding calculation logic. Create one
variable (such as shouldShowXButton or similar) that accurately reflects when
the X button will be displayed, and use this variable in both locations instead
of mixing showDeleteButtonBase and showDeleteButton.

---

Outside diff comments:
In `@src/renderer/src/components/skills/bulkDeleteCopy.tsx`:
- Around line 76-92: The hasBaseTrashCopy condition does not account for
protected skills, so when some skills are protected (protectedCount > 0), the
copy still displays "moves all skills to trash" even though not all items can be
moved due to the protection. Add a check for protectedCount === 0 to the
hasBaseTrashCopy condition alongside the existing checks for orphanCleanupCount,
orphanRescanCount, and staleDeleteCount. This ensures the "base trash copy"
message only shows when there are genuinely no protected items. The same fix
should be applied at the other affected site (around line 123-127) where similar
logic handles protected item scenarios.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c45d9822-daa7-4f01-87b8-00d58bf72cc2

📥 Commits

Reviewing files that changed from the base of the PR and between 36bc989 and e75519a.

📒 Files selected for processing (21)
  • DESIGN.md
  • TODOS.md
  • src/renderer/src/components/layout/MainContent.browser.test.tsx
  • src/renderer/src/components/layout/MainContent.selectionToolbar.browser.test.tsx
  • src/renderer/src/components/layout/MainContent.tsx
  • src/renderer/src/components/skills/SkillItem.browser.test.tsx
  • src/renderer/src/components/skills/SkillItem.tsx
  • src/renderer/src/components/skills/SkillsList.browser.test.tsx
  • src/renderer/src/components/skills/bulkDeleteCopy.test.ts
  • src/renderer/src/components/skills/bulkDeleteCopy.tsx
  • src/renderer/src/components/skills/reviewedDestructiveTargets.test.ts
  • src/renderer/src/components/skills/reviewedDestructiveTargets.ts
  • src/renderer/src/components/skills/skillItemHelpers.test.ts
  • src/renderer/src/components/skills/skillItemHelpers.ts
  • src/renderer/src/redux/migrations.ts
  • src/renderer/src/redux/slices/protectSlice.test.ts
  • src/renderer/src/redux/slices/protectSlice.ts
  • src/renderer/src/redux/slices/uiSlice.test.ts
  • src/renderer/src/redux/slices/uiSlice.ts
  • src/renderer/src/redux/store.test.ts
  • src/renderer/src/redux/store.ts

Comment thread src/renderer/src/components/skills/SkillItem.tsx
Comment thread src/renderer/src/components/skills/SkillItem.tsx
- Pass real protection state to partitionGlobalDeleteTargets in single-item
  delete handler for defense-in-depth (no longer relies solely on UI gate)
- Use showDeleteButtonBase (pre-gate) for CardContent padding so the lock
  button position and the right-padding slot count always agree

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/renderer/src/components/skills/SkillItem.tsx (1)

123-128: ⚠️ Potential issue | 🟡 Minor

right-22 は Tailwind で生成されません。任意値または拡張が必要です。

Tailwind 4.3.1 のデフォルト spacing には 22 が含まれず、tailwind.config.ts に拡張もありません。生成 CSS に right-22 がないため、3 ボタン全て表示時(lock + bookmark + X)に ProtectButton の位置計算が失敗しています。

right-[5.5rem] などの任意値に修正するか、spacing を拡張してください。加えて、このスロット→クラス分岐ロジックを skillItemHelpers.ts の pure helper に抽出すれば、組み合わせを単体テストできます(coding guideline の「Extract conditional rendering logic into pure helper functions」に準拠)。

修正例
   const rightClass =
     showBookmark && hasXButton
-      ? 'right-22'
+      ? 'right-[5.5rem]'
       : hasXButton || showBookmark
         ? 'right-11'
         : 'right-0'

または、ヘルパー関数化:

// skillItemHelpers.ts に追加
export function getProtectButtonRightClass(flags: {
  showBookmark: boolean
  showUnlinkButton: boolean
  showDeleteButton: boolean
}): 'right-[5.5rem]' | 'right-11' | 'right-0' {
  const hasXButton = flags.showUnlinkButton || flags.showDeleteButton
  if (flags.showBookmark && hasXButton) return 'right-[5.5rem]'
  if (flags.showBookmark || hasXButton) return 'right-11'
  return 'right-0'
}
🤖 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/renderer/src/components/skills/SkillItem.tsx` around lines 123 - 128, The
conditional logic for rightClass in SkillItem.tsx is using `right-22`, which is
not a valid Tailwind spacing value in version 4.3.1 and will not generate any
CSS. Replace `right-22` with the arbitrary Tailwind value `right-[5.5rem]` in
the ternary expression. Additionally, extract this entire conditional logic into
a pure helper function called getProtectButtonRightClass in skillItemHelpers.ts
that accepts an object with showBookmark, showUnlinkButton (hasXButton
condition), and showDeleteButton flags, and returns the appropriate class name
string. This extraction follows the coding guideline for extracting conditional
rendering logic and allows the combination logic to be unit tested independently
before being used in the component.

Source: Coding guidelines

🤖 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/renderer/src/components/skills/SkillItem.tsx`:
- Around line 483-489: The code is widening `SkillName` to `string` when
creating the set passed to `partitionGlobalDeleteTargets`, which breaks the type
contract that expects `ReadonlySet<Skill['name']>` (i.e.,
`ReadonlySet<SkillName>`). Remove the `as string` type cast from the conditional
expression and ensure both branches of the ternary operator create a
`Set<SkillName>` (or appropriate readonly set of SkillName) without widening to
the string type. This maintains type safety and prevents contract breakage if
SkillName becomes a branded type in the future.

---

Outside diff comments:
In `@src/renderer/src/components/skills/SkillItem.tsx`:
- Around line 123-128: The conditional logic for rightClass in SkillItem.tsx is
using `right-22`, which is not a valid Tailwind spacing value in version 4.3.1
and will not generate any CSS. Replace `right-22` with the arbitrary Tailwind
value `right-[5.5rem]` in the ternary expression. Additionally, extract this
entire conditional logic into a pure helper function called
getProtectButtonRightClass in skillItemHelpers.ts that accepts an object with
showBookmark, showUnlinkButton (hasXButton condition), and showDeleteButton
flags, and returns the appropriate class name string. This extraction follows
the coding guideline for extracting conditional rendering logic and allows the
combination logic to be unit tested independently before being used in the
component.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 92b3f1af-d4e8-45e1-b77c-77bf29b6e8a1

📥 Commits

Reviewing files that changed from the base of the PR and between e75519a and e7c8d74.

📒 Files selected for processing (1)
  • src/renderer/src/components/skills/SkillItem.tsx

Comment thread src/renderer/src/components/skills/SkillItem.tsx
Avoid widening SkillName to string when building the protected-names set
passed to partitionGlobalDeleteTargets
@ryota-murakami ryota-murakami merged commit fea1edd into main Jun 16, 2026
11 checks passed
@ryota-murakami ryota-murakami deleted the feat/skill-deletion-protection branch June 16, 2026 15:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants