persistent-user-settings-with-fallback
ユーザーが選択した設定(パス、ロケーションなど)を保持し、デフォルト値への自動フォールバック機能を備えたパターン
description の原文を見る
Pattern for persisting user-chosen settings (paths, locations) with automatic fallback to defaults
SKILL.md 本文
SKILL.md 翻訳
---
name: "persistent-user-settings-with-fallback"
description: "Pattern for persisting user-chosen settings (paths, locations) with automatic fallback to defaults"
domain: "settings-management"
confidence: "high"
source: "material-library-repointing-design-review"
applies_to: ["desktop-host", "persistence"]
---
## コンテキスト
アプリが重要なリソース(ファイルパス、ディレクトリ、データストア)のカスタムロケーションをユーザーに選択させる必要があり、カスタムロケーションが利用不可能になる場合(削除、USB ドライブの切断、パーミッション取り消し)を適切に処理する必要がある場合に、このパターンを使用します。
**例:** マテリアルライブラリの再ポインティング、プロジェクトディレクトリの選択、カスタムワークスペースロケーション、データベースファイルロケーション。
## パターン
### 1. 「設定」レイヤーと「リソース」レイヤーの分離
- **設定レイヤー:** ユーザーの選択肢を保存する小さくフォーカスされた JSON ファイル(例: `app-settings.json`)。1 つのファイル、1 つのスキーマ、アトミックに読み書きされます。
- **リソースレイヤー:** 解決されたパスを使用する実際のビジネスロジック(リポジトリ、ファイル I/O など)。
```csharp
// Settings: what user chose
public class AppSettings
{
public string? ActiveLibraryPath { get; set; } // null = use default
}
// Resolution: get the actual path to use
public static string ResolveLibraryPath(AppSettings settings)
{
return settings.ActiveLibraryPath ?? DefaultPath;
}
// Resource: doesn't know or care where it came from
var repository = new JsonMaterialRepository(ResolveLibraryPath(settings));
2. 起動時のサイレントフォールバック実装
カスタム設定が利用不可能なリソースを指している場合、失敗しません。 サイレントにデフォルトにフォールバックし、非モーダルなステータスメッセージをレポートします。
public async Task InitializeAsync()
{
var settings = LoadAppSettings();
var resolvedPath = ResolveLibraryPath(settings);
try
{
// Try to use user's choice
_repository = new JsonMaterialRepository(resolvedPath);
await _repository.GetAllAsync(); // Validate it works
_statusMessage = $"Loaded materials from {resolvedPath}";
}
catch (Exception ex)
{
// Fall back to default silently
_repository = new JsonMaterialRepository(DefaultPath);
settings.ActiveLibraryPath = null;
await SaveAppSettingsAsync(settings); // Persist the revert
_statusMessage = $"Could not access custom location ({ex.Message}). Using default.";
}
}
3. ブリッジメッセージとして変更/復元を公開
2 つのハンドラー:
change-*-location: 入力を検証し、必要に応じて作成し、設定を保持し、確認されたパスを返します。restore-default-*-location: 設定をクリアし、必要に応じてデフォルトを作成/検証し、デフォルトパスを返します。
両方のハンドラーはべき等性があり、エッジケース(パスが存在しない、パーミッション拒否、ファイル破損)を考慮します。
dispatcher.Register<ChangeLibraryLocationRequest>(
"change-library-location",
async (request, cancellationToken) =>
{
try
{
// Validate & create if needed
var validatedPath = ValidateAndPrepareLibraryPath(request.NewLibraryPath);
// Persist setting
var settings = LoadAppSettings();
settings.ActiveLibraryPath = validatedPath;
await SaveAppSettingsAsync(settings);
// Reload repository with new path
_repository = new JsonMaterialRepository(validatedPath);
await _repository.GetAllAsync(); // Sanity check
return new ChangeLibraryLocationResponse
{
Success = true,
LibraryPath = validatedPath,
Message = $"Material library now points to {validatedPath}"
};
}
catch (Exception ex)
{
return new ChangeLibraryLocationResponse
{
Success = false,
Error = BridgeError.Create("invalid-path", ex.Message)
};
}
});
4. ユーザーの明確性のためのエラーコード設計
ジェネリックコードを再利用しますが、ユーザーメッセージにコンテキストを追加します:
public static string? ResolveUserMessage(string code, string message, string? userMessage)
{
return code switch
{
"invalid-path" =>
"The file path is invalid or cannot be accessed. Choose a different location.",
"path-not-accessible" =>
"The directory does not exist and could not be created. Check permissions.",
"write-permission-denied" =>
"You don't have permission to write to that location. Choose a different folder.",
"invalid-library-file" =>
"The file at that location is not a valid material library. Choose a different file.",
// ... etc
_ => message
};
}
5. 設定ファイルのフォーマット
最小限で人間が読める形式に保ちます:
{
"version": 1,
"activeLibraryPath": "C:\\Users\\Alice\\Documents\\MyMaterials.json"
}
または null を使用(デフォルトを使用):
{
"version": 1,
"activeLibraryPath": null
}
読み込み戦略:
- ファイルが存在しない → 空として扱う(すべての設定がデフォルト)。
- ファイルが破損した JSON → 警告をログに記録し、無視し、デフォルトを使用。
- 設定が null → 「設定されていない、組み込みデフォルトを使用」として扱う。
6. テスト
ユニットテスト:
- パスが見つからないときのフォールバック。
- ファイルが破損しているときのフォールバック。
- ディレクトリが書き込み不可のときのフォールバック。
- 設定ファイルが正しく作成および更新されること。
- null/見つからない設定フィールドが適切に処理されること。
インテグレーションテスト:
- ロケーションを変更し、設定がアプリ再起動後も永続化されることを確認。
- カスタムファイルを削除し、アプリを再度開いて、フォールバックが発生することを確認。
- カスタムパスを設定した後にデフォルトを復元。
- 同時アクセス(関連する場合)。
[Fact]
public async Task WhenCustomPathIsDeletedAtStartup_FallsBackToDefault()
{
var settings = new AppSettings { ActiveLibraryPath = "/invalid/path" };
var host = new TestHost(settings);
await host.InitializeAsync();
Assert.Equal(DefaultPath, host.ResolvedPath);
Assert.Null(host.LoadedSettings.ActiveLibraryPath); // Reverted
}
6b. 明示的な再ポインティングと暗黙的なリカバリーの区別
アーキテクチャがリポジトリ/リソースレイヤーに永続化されたロケーションの所有を強制する場合(別の設定ホストではなく)、2 つの異なるコードパスを維持します:
- 明示的な再ポインティング/復元コマンド: ユーザーが意図的にロケーションを選択したため、パスを正規化し、検証し、機能契約の一部である場合はファイルを作成/シード します。
- 暗黙的なリロード/再起動リカバリー: アプリは以前に保存されたカスタムパスを再度開いています。そのファイルが現在見つからない、破損している、または検証に失敗した場合、サイレントに再作成しません。 正規のデフォルトリソースにフォールバックし、保存されたオーバーライドをベストエフォートでクリアします。
この区別により、リカバリーは決定的で説明可能になります。また、ローダーがみだりに欠落ファイルをシードするため、破損したカスタムパスを永遠に復活させることも回避できます。
7. UI コンパニオンパターン: ロケーション + リフレッシュされたデータを一緒に返す
WebView/デスクトップスプリットの場合、ロケーション変更ハンドラーは確認されたロケーションメタデータとリロードされたリソースペイロードの両方を 1 つのレスポンスで返す必要があります。フロントエンドでは、list、change、および restore default を 1 つの共有状態ヘルパーを通じてファネリングします。これにより、テーブルコンテンツ、選択、および現在のパス UI が同じリデューサー遷移でリフレッシュされます。
type MaterialLibraryResponse = {
success: boolean;
materials: Material[];
libraryLocation?: MaterialLibraryLocation | null;
};
function applyMaterialLibraryResponse(response: MaterialLibraryResponse) {
dispatch({
type: 'materials-loaded',
materials: response.materials,
materialLibraryLocation: response.libraryLocation,
selectedMaterialId: pickMaterialId(
response.materials,
selectionContext.importResponse,
selectionContext.selectedMaterialId,
),
});
}
8. UI 配置: パスコントロールを変更するリソースと一緒に保つ
現在のパスと Choose… / Restore default アクションを、リソースを所有するワークスペース内に表示します(PanelNester の場合は Materials ページの Library パネル)。グローバルクロムには表示しません。トップレベルクロムはアプリ全体のホスト状態用に予約します。パス管理は、リフレッシュするテーブルの隣に配置されると、スキャンが容易になります。
ページがそのリソースの Refresh アクションも公開している場合、同じローカルコントロール行に保ちます。Refresh を別のページクロムに分割すると、パスコピー、テーブルコンテンツ、および選択がすべて同じブリッジレスポンスを反映しているかどうかを推測しづらくなります。
なぜこれが機能するのか
✅ グレースフルデグラデーション: ユーザーの選択が利用不可能になった場合、アプリは動作し続けます。 ✅ モーダルエラーなし: フォールバックはサイレント。ステータスメッセージは情報提供のみです。 ✅ ドメインロジックに透過的: リソースレイヤーは設定について知りません。 ✅ 後方互換性: 欠落した設定ファイル = デフォルトを使用、マイグレーション不要。 ✅ ユーザー期待: 「私は X を選びました。壊れたら Y に戻ってください」は直感的です。
アンチパターン
❌ ユーザーの選択をリソース内に埋め込まないでください: 結合につながり、テストを困難にします。
❌ フォールバックで例外をスローしないでください: キャッチして代わりにサイレントに戻します。
❌ UI でロケーション更新をリソースリロードから分割しないでください: 新しいパスのみを返すと、古いテーブル/選択状態を残すのが簡単です。
❌ リソース固有のパスコントロールをグローバルクロムに移動しないでください: リフレッシュされたデータが 1 つのページに存在する場合、オペレーターが必要なコンテキストが隠れます。
❌ レデューサーで表示されるロケーションを前の状態に nullish フォールバックしないでください: null/undefined を返すブリッジレスポンスは、古いパス UI をクリアする必要があります。それを保持しないでください。
❌ ユーザーインタラクションなしでリカバリーが必要な場合は避けてください: 自動フォールバックの方が UX が優れています。
❌ 設定にバイナリ/暗号化形式を使用しないでください: デバッグ可能性のため JSON に保ちます。
バリアント
- 複数の設定: 5 つ以上のユーザー選択(テーマ、ワークスペースレイアウト など)がある場合、5 つの個別ファイルではなく、5 つのフィールドを持つ 1 つの設定ファイルを使用します。
- スコープ設定: プロジェクト固有のカスタムパス(例: プロジェクト固有のマテリアルファイル)は同じパターンを使用しますが、プロジェクトメタデータに保存します。
- リポジトリ所有のリカバリーシーム: パス永続化をリソースレイヤーの外側に保つことができない場合、明示的な
RepointAsync/RestoreDefaultAsyncオペレーションを公開し、上記で説明したように暗黙的な読み込みフォールバックロジックを分離してください。 - フォールバック時の通知: 重要な場合は、サイレントなステータスメッセージではなく、ステータスバナーまたはトーストでユーザーに通知します。
リファレンス
- 関連スキル:
webview2-user-data-relocation— ランタイムプロファイルロケーションの類似パターン。 - PanelNester 実装:
.squad/decisions/inbox/ripley-material-library-repointing.md
3b. デスクトップ/Web ブリッジ向けのホスト所有ピッカーバリアント
フロントエンドがロケーション変更を要求するだけの場合、ブリッジペイロードを空にしておき、デスクトップホストにネイティブピッカーを所有させます。ファイルベースリソース用にセーブスタイルダイアログを使用して、ユーザーがファイルがまだ存在しないときでも新しいファイルパスをポイントでき、アクションが確認されたデータを上書きするのではなくライブラリロケーションを選択する場合は上書きプロンプトを無効にします。
dispatcher.Register<ChooseLibraryLocationRequest>(
"choose-library-location",
async (_, cancellationToken) =>
{
var dialogResult = await fileDialogService.SaveAsync(
new SaveFileDialogRequest(
"Choose library location",
"materials.json",
[new FileDialogFilter("Library files", ["json"])],
".json",
overwritePrompt: false),
cancellationToken);
if (!dialogResult.Success || string.IsNullOrWhiteSpace(dialogResult.FilePath))
{
return ChooseLibraryLocationResponse.Cancelled();
}
var location = await locationService.RepointAsync(dialogResult.FilePath, cancellationToken);
var materials = await materialService.ListAsync(cancellationToken);
return new ChooseLibraryLocationResponse(true, materials, location, null, "Library updated.");
});
このバリアントが重要な理由:
- Web サイドはネイティブファイルダイアログの詳細について無知のままです。
- ホストはリフレッシュされたデータと確認されたロケーションメタデータの両方を含む 1 つの権威あるレスポンスを返します。
- 明示的な選択フロー は再起動時のフォールバックリカバリーから分離されたままです。
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- majiayu000
- ライセンス
- MIT
- 最終更新
- 2026/5/4
Source: https://github.com/majiayu000/claude-skill-registry / ライセンス: MIT