From d139511e31dfc0ff9d0ef8d4c5b5c6a108e4868f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Thu, 24 Sep 2026 20:35:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?BYOK=20=E3=81=AB=20Ollama=EF=BC=88=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=AB=EF=BC=89=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20(B18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIProvider.ollama を追加(キー不要、既定 http://localhost:11434)。ATS の NSAllowsLocalNetworking を Info.plist に足して loopback への平文 http を許可し、 AIConfig.baseURL も loopback 相手なら http の上書きを通すようにした。 AIClient のキー必須ガードは provider.requiresAPIKey で分岐。リクエスト/レスポンス/ SSE は OpenAI 互換の /v1/chat/completions をそのまま流用する。 実機の Ollama(llama3.2:1b)で送信・ストリーミング両方を確認済み。 Co-Authored-By: Claude Sonnet 5 --- Sources/MrEditorCore/Core/AIClient.swift | 7 ++- Sources/MrEditorCore/Core/AIConfig.swift | 34 ++++++++++-- Sources/MrEditorCore/Core/AIRequest.swift | 10 +++- Sources/MrEditorCore/Core/AIStream.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../UI/PreferencesWindowController.swift | 8 +++ Tests/MrEditorTests/AITests.swift | 55 +++++++++++++++++++ scripts/make_app.sh | 8 +++ 9 files changed, 116 insertions(+), 10 deletions(-) diff --git a/Sources/MrEditorCore/Core/AIClient.swift b/Sources/MrEditorCore/Core/AIClient.swift index f8b356c..8287e49 100644 --- a/Sources/MrEditorCore/Core/AIClient.swift +++ b/Sources/MrEditorCore/Core/AIClient.swift @@ -48,10 +48,12 @@ enum AIClient { completion: @escaping (Result) -> Void) -> AIStreamHandle { let handle = AIStreamHandle(onDelta: onDelta, completion: completion) let config = AppSettings.aiConfig - guard let key = Keychain.get(account: config.provider.keychainAccount), !key.isEmpty else { + let storedKey = Keychain.get(account: config.provider.keychainAccount) ?? "" + guard !config.provider.requiresAPIKey || !storedKey.isEmpty else { handle.failLater(.notConfigured) return handle } + let key = storedKey let request: URLRequest do { var r = try AIRequestBuilder.makeRequest(prompt, config: config, apiKey: key, stream: true) @@ -71,7 +73,8 @@ enum AIClient { static func send(_ prompt: AIPrompt, completion: @escaping (Result) -> Void) { let config = AppSettings.aiConfig - guard let key = Keychain.get(account: config.provider.keychainAccount), !key.isEmpty else { + let key = Keychain.get(account: config.provider.keychainAccount) ?? "" + guard !config.provider.requiresAPIKey || !key.isEmpty else { DispatchQueue.main.async { completion(.failure(.notConfigured)) } return } diff --git a/Sources/MrEditorCore/Core/AIConfig.swift b/Sources/MrEditorCore/Core/AIConfig.swift index f4a8604..c90f9fc 100644 --- a/Sources/MrEditorCore/Core/AIConfig.swift +++ b/Sources/MrEditorCore/Core/AIConfig.swift @@ -6,21 +6,31 @@ enum AIProvider: String, CaseIterable, Codable { case anthropic case openAI case gemini + /// ローカルの Ollama(OpenAI 互換 API)。キーは要らない。B18。 + case ollama var displayName: String { switch self { case .anthropic: return "Anthropic (Claude)" case .openAI: return "OpenAI" case .gemini: return "Google (Gemini)" + case .ollama: return "Ollama (local)" } } + /// キーが要るか。**Ollama だけ要らない**(ローカルなので鍵で守る相手がいない)。 + /// [[AIClient]] のキー必須ガード、[[AIRequestBuilder.makeRequest]] の空キー拒否はここを見る。 + var requiresAPIKey: Bool { self != .ollama } + /// 既定のエンドポイント。config の baseURLOverride で上書きでき、OpenAI 互換サーバへ向けられる。 + /// Ollama だけ既定が http ── ローカル向けの ATS 例外(NSAllowsLocalNetworking)を + /// Info.plist に足してあるので、127.0.0.1 / localhost / .local 相手はこれで届く。 var defaultBaseURL: URL { switch self { case .anthropic: return URL(string: "https://api.anthropic.com")! case .openAI: return URL(string: "https://api.openai.com")! case .gemini: return URL(string: "https://generativelanguage.googleapis.com")! + case .ollama: return URL(string: "http://localhost:11434")! } } @@ -41,6 +51,10 @@ enum AIProvider: String, CaseIterable, Codable { // 次々引退させるので、一覧に固定版を焼き込むと腐る。使いたい人は打ち込めばよく、 // 接続テストに通れば次から一覧に出る。 return ["gemini-flash-latest", "gemini-pro-latest", "gemini-flash-lite-latest"] + case .ollama: + // インストールしたモデルは人によって違う。`ollama list` で入っているものが正で、 + // ここは「よくある名前」の手がかりでしかない。 + return ["llama3.2", "qwen2.5", "mistral", "gemma2"] } } @@ -64,6 +78,7 @@ enum AIProvider: String, CaseIterable, Codable { case .anthropic: return "claude-opus-4-8" case .openAI: return "gpt-4o" case .gemini: return "gemini-flash-latest" + case .ollama: return "llama3.2" } } @@ -76,17 +91,28 @@ enum AIProvider: String, CaseIterable, Codable { struct AIConfig: Equatable, Codable { var provider: AIProvider var model: String - /// OpenAI 互換サーバ等へ向けるためのベース URL 上書き(空=既定)。**https 限定** - /// (配布 .app は ATS 例外なし=平文 http は実機で -1022。[[ats-url-fetch-https-only]])。 + /// OpenAI 互換サーバ等へ向けるためのベース URL 上書き(空=既定)。**https 限定、 + /// ただし loopback/.local 相手は http も通す**(配布 .app は ATS に + /// `NSAllowsLocalNetworking` を足してあるので、そこだけ平文が届く。 + /// それ以外の http は実機で -1022。[[ats-url-fetch-https-only]])。 var baseURLOverride: String static let `default` = AIConfig(provider: .anthropic, model: AIProvider.anthropic.defaultModel, baseURLOverride: "") - /// 実効ベース URL。上書きが有効な https URL ならそれ、無ければプロバイダ既定。 + /// ループバック/mDNS など、ATS の `NSAllowsLocalNetworking` が平文でも通す相手か。 + private static func isLocalHost(_ host: String) -> Bool { + host == "localhost" || host == "127.0.0.1" || host == "::1" || host.hasSuffix(".local") + } + + /// 実効ベース URL。上書きが有効な https URL、または loopback 相手の http ならそれ、 + /// 無ければプロバイダ既定(Ollama の既定自体が http なのでここは通らない)。 var baseURL: URL { - if let u = URL(string: baseURLOverride), u.scheme == "https", u.host != nil { return u } + if let u = URL(string: baseURLOverride), let host = u.host, + u.scheme == "https" || (u.scheme == "http" && Self.isLocalHost(host)) { + return u + } return provider.defaultBaseURL } } diff --git a/Sources/MrEditorCore/Core/AIRequest.swift b/Sources/MrEditorCore/Core/AIRequest.swift index 5b83c96..02a3588 100644 --- a/Sources/MrEditorCore/Core/AIRequest.swift +++ b/Sources/MrEditorCore/Core/AIRequest.swift @@ -23,10 +23,14 @@ enum AIRequestBuilder { /// `stream` が真なら SSE(`text/event-stream`)で受け取る形にする=差分表示用。 static func makeRequest(_ prompt: AIPrompt, config: AIConfig, apiKey: String, stream: Bool = false) throws -> URLRequest { - guard !apiKey.isEmpty else { throw AIError(message: "missing API key") } + guard !apiKey.isEmpty || !config.provider.requiresAPIKey else { + throw AIError(message: "missing API key") + } switch config.provider { case .anthropic: return anthropic(prompt, config: config, apiKey: apiKey, stream: stream) - case .openAI: return openAI(prompt, config: config, apiKey: apiKey, stream: stream) + // Ollama は OpenAI 互換の `/v1/chat/completions` をそのまま話す。キーが空でも + // `Authorization: Bearer ` が付くだけで、Ollama 側は見ていないので害はない。 + case .openAI, .ollama: return openAI(prompt, config: config, apiKey: apiKey, stream: stream) case .gemini: return try gemini(prompt, config: config, apiKey: apiKey, stream: stream) } } @@ -139,7 +143,7 @@ enum AIRequestBuilder { }.joined() guard !text.isEmpty else { throw AIError.emptyResponse } return text - case .openAI: + case .openAI, .ollama: guard let choices = obj["choices"] as? [[String: Any]], let message = choices.first?["message"] as? [String: Any], let text = message["content"] as? String, !text.isEmpty else { diff --git a/Sources/MrEditorCore/Core/AIStream.swift b/Sources/MrEditorCore/Core/AIStream.swift index 47a2739..c137086 100644 --- a/Sources/MrEditorCore/Core/AIStream.swift +++ b/Sources/MrEditorCore/Core/AIStream.swift @@ -65,7 +65,7 @@ struct AIStreamDecoder { default: return [] // ping / message_start / usage 等 } - case .openAI: + case .openAI, .ollama: guard let choices = obj["choices"] as? [[String: Any]] else { return [] } let text = choices.compactMap { ($0["delta"] as? [String: Any])?["content"] as? String }.joined() return text.isEmpty ? [] : [.delta(text)] diff --git a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings index 9696021..0e3ad9c 100644 --- a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings @@ -331,6 +331,7 @@ "prefs.ai.apiKey" = "API key"; "prefs.ai.baseURL" = "Base URL (optional)"; "prefs.ai.baseURLPlaceholder" = "OpenAI-compatible server (https only)"; +"prefs.ai.baseURLNotNeeded" = "Not needed (uses the local default URL)"; "prefs.ai.note" = "Your key is stored in the Keychain. You bring your own key (BYOK); it is used only for single-shot analysis of the current selection.\nA model that is not in the list can be typed in. Once it passes the connection test, it appears in the list from then on."; "prefs.ai.test" = "Test connection"; "prefs.ai.testing" = "Testing…"; diff --git a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings index f4656e9..5d45d0d 100644 --- a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings @@ -331,6 +331,7 @@ "prefs.ai.apiKey" = "API キー"; "prefs.ai.baseURL" = "ベース URL(任意)"; "prefs.ai.baseURLPlaceholder" = "OpenAI 互換サーバ(https のみ)"; +"prefs.ai.baseURLNotNeeded" = "不要(ローカルの既定 URL を使用)"; "prefs.ai.note" = "キーは Keychain に保存されます。原価はあなたの鍵持ちです(BYOK)。選択範囲の単発解析にのみ使います。\n一覧に無いモデルは直接入力できます。接続テストに通ると、次から一覧に出ます。"; "prefs.ai.test" = "接続テスト"; "prefs.ai.testing" = "確認中…"; diff --git a/Sources/MrEditorCore/UI/PreferencesWindowController.swift b/Sources/MrEditorCore/UI/PreferencesWindowController.swift index ae2527d..fabc4e3 100644 --- a/Sources/MrEditorCore/UI/PreferencesWindowController.swift +++ b/Sources/MrEditorCore/UI/PreferencesWindowController.swift @@ -608,6 +608,14 @@ private final class AIPaneViewController: NSViewController, NSTextFieldDelegate, baseURLField.stringValue = config.baseURLOverride keyField.stringValue = Keychain.get(account: config.provider.keychainAccount) ?? "" testResultLabel.stringValue = "" + + // Ollama はローカルの既定 URL に鍵無しで届くので、両方とも打つ意味が無い。 + // 空にはしない(打ってあったものを消したくない)── 触れなくするだけ。 + let needsRemoteConfig = config.provider.requiresAPIKey + keyField.isEnabled = needsRemoteConfig + baseURLField.isEnabled = needsRemoteConfig + baseURLField.placeholderString = needsRemoteConfig + ? L("prefs.ai.baseURLPlaceholder") : L("prefs.ai.baseURLNotNeeded") } private var currentProvider: AIProvider { diff --git a/Tests/MrEditorTests/AITests.swift b/Tests/MrEditorTests/AITests.swift index aa3fd7b..80ff08e 100644 --- a/Tests/MrEditorTests/AITests.swift +++ b/Tests/MrEditorTests/AITests.swift @@ -135,6 +135,61 @@ final class AITests: XCTestCase { AIPrompt(system: nil, user: "x", maxTokens: 10), config: .default, apiKey: "")) } + // MARK: - Ollama(ローカル・キー不要。B18) + + func testOllamaDoesNotRequireAPIKey() { + XCTAssertFalse(AIProvider.ollama.requiresAPIKey) + for p: AIProvider in [.anthropic, .openAI, .gemini] { XCTAssertTrue(p.requiresAPIKey) } + } + + /// 空キーでも投げない(他プロバイダは `testMissingKeyThrows` の通り投げる)。 + func testMakeOllamaRequestWithoutKey() throws { + let cfg = AIConfig(provider: .ollama, model: "llama3.2", baseURLOverride: "") + let req = try AIRequestBuilder.makeRequest( + AIPrompt(system: "SYS", user: "hi", maxTokens: 256), config: cfg, apiKey: "") + XCTAssertEqual(req.url?.absoluteString, "http://localhost:11434/v1/chat/completions") + // 空でも Bearer ヘッダ自体は付く(Ollama 側は見ない)。 + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer ") + let msgs = body(req)["messages"] as? [[String: String]] + XCTAssertEqual(msgs?[1]["content"], "hi") + } + + /// 既定が http のまま(loopback は ATS の NSAllowsLocalNetworking で届く)。 + func testOllamaDefaultBaseURLIsPlainHTTP() { + let cfg = AIConfig(provider: .ollama, model: "llama3.2", baseURLOverride: "") + XCTAssertEqual(cfg.baseURL, URL(string: "http://localhost:11434")) + } + + /// 上書きも loopback 相手なら http を通す(別ポートで動かしている場合等)。 + /// loopback 以外の http は従来どおり拒否して既定へ落ちる。 + func testBaseURLOverrideAllowsLoopbackHTTP() { + var cfg = AIConfig(provider: .ollama, model: "m", baseURLOverride: "http://127.0.0.1:1234") + XCTAssertEqual(cfg.baseURL, URL(string: "http://127.0.0.1:1234")) + cfg.baseURLOverride = "http://localhost:9999" + XCTAssertEqual(cfg.baseURL, URL(string: "http://localhost:9999")) + cfg.baseURLOverride = "http://not-local.example.com" + XCTAssertEqual(cfg.baseURL, AIProvider.ollama.defaultBaseURL) + } + + func testParseOllamaResponse() throws { + let json = #"{"choices":[{"message":{"role":"assistant","content":"try -Xmx"}}]}"# + let text = try AIRequestBuilder.parseResponse(json.data(using: .utf8)!, provider: .ollama) + XCTAssertEqual(text, "try -Xmx") + } + + func testDecodeOllamaStream() { + var d = AIStreamDecoder(provider: .ollama) + let events = feed(&d, """ + data: {"choices":[{"delta":{"content":"try "}}]} + + data: {"choices":[{"delta":{"content":"-Xmx"}}]} + + data: [DONE] + + """) + XCTAssertEqual(events, [.delta("try "), .delta("-Xmx"), .done]) + } + // MARK: - AIRequestBuilder(解析) func testParseAnthropicResponse() throws { diff --git a/scripts/make_app.sh b/scripts/make_app.sh index 06603d7..401360e 100755 --- a/scripts/make_app.sh +++ b/scripts/make_app.sh @@ -171,6 +171,14 @@ fi) NSPrincipalClass NSApplication + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + PLIST From 6ceefdeb45346a2e3652a3a0ec8749af8063574f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Thu, 24 Sep 2026 21:36:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF?= =?UTF-8?q?=E4=B8=AD=E3=81=AB=E7=BD=AE=E6=8F=9B=E3=83=9C=E3=82=BF=E3=83=B3?= =?UTF-8?q?=E3=81=8C=E6=9C=89=E5=8A=B9=E3=81=AA=E3=81=BE=E3=81=BE=E6=AE=8B?= =?UTF-8?q?=E3=82=8B=E3=83=90=E3=82=B0=E3=82=92=E7=9B=B4=E3=81=97=E3=80=81?= =?UTF-8?q?=E7=90=86=E7=94=B1=E3=82=82=E5=87=BA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検索バーを開くとき(showSearch/ツールバーのフィルタボタン/分析ペインからの 絞り込み)に、置換の可否を引き直す処理がフィルタの適用より先に走っていて、 フィルタ中でも置換ボタンが押せる見た目のまま残っていた。押すと canEdit の ガードで無言のままビープするだけで、何が起きているか分からない作りだった。 3箇所の呼び出し順を入れ替えてフィルタ適用を先に済ませ、置換不可の理由を 読み取り専用バナーで明示するようにした(「大きすぎる」固定文だったのを、 フィルタ中/JSONクエリ中で文面を出し分ける形に変更)。 Co-Authored-By: Claude Sonnet 5 --- .../MrEditorCore/MainWindowController.swift | 32 +++++++++++++++---- .../Resources/en.lproj/Localizable.strings | 2 ++ .../Resources/ja.lproj/Localizable.strings | 2 ++ Sources/MrEditorCore/UI/ReadOnlyBanner.swift | 24 +++++++++++++- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/Sources/MrEditorCore/MainWindowController.swift b/Sources/MrEditorCore/MainWindowController.swift index c50526e..208912f 100644 --- a/Sources/MrEditorCore/MainWindowController.swift +++ b/Sources/MrEditorCore/MainWindowController.swift @@ -1256,14 +1256,24 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { persistSession() } - /// 読み取り専用バナーの表示可否を更新する(編集不可のペイン=LargeFileViewer のときだけ出す)。 + /// 読み取り専用バナーの表示可否・文面を更新する。 + /// **理由によって文面を変える**──フィルタ中や JSON クエリ中の小さなファイルにまで + /// 「大きすぎる」を出すと、その場で嘘になってバグに見える(本人の指摘、2026-09-24)。 private func updateReadOnlyBanner() { // 構造化表示による読み取り専用は専用バナーで案内するため除外する。 // diff も除外する(「大きすぎて編集できません」は嘘。そもそも編集する画面ではない)。 - let isReadOnly = activeViewer != nil && !(activeViewer?.canEdit ?? false) - && activeViewer?.structuredMode == nil - && !(activeViewer is DiffViewer) - readOnlyBanner.isHidden = !(isReadOnly && !readOnlyBannerDismissed) + guard let v = activeViewer, !v.canEdit, v.structuredMode == nil, !(v is DiffViewer) else { + readOnlyBanner.isHidden = true + return + } + if v.filterMatchLines != nil { + readOnlyBanner.reason = .filtered + } else if v.jsonQueryIsActive { + readOnlyBanner.reason = .jsonQuery + } else { + readOnlyBanner.reason = .tooLarge + } + readOnlyBanner.isHidden = readOnlyBannerDismissed } /// アクティブなドキュメントを閉じる(なければウィンドウを閉じる)。未保存なら確認する。 @@ -1664,11 +1674,15 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { func showSearch() { guard let v = activeViewer, v.supportsSearch else { NSSound.beep(); return } - refreshSearchBarCapabilities() searchBar.isHidden = false // 前に自分で漏斗を入れていたなら、その状態で開く。構造化されたものを読むとき // 主目的は「絞る」ほうで、そこへ毎回 1 手かけ直すのが B2 の詰まりだった。 + // **置換の可否を引き直すより先に当てる**── 順序が逆だと、フィルタ適用前の + // 「置換できる」判定のまま置換ボタンが有効に残り、押すと canEdit のガードで + // 問答無用にビープする(フィルタ中に編集を許さないのは正しいが、ボタンだけ + // 古い状態のままになる不具合だった)。 applyRememberedFilter(to: v) + refreshSearchBarCapabilities() searchBar.focusField() } @@ -1691,6 +1705,8 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { // 漏斗が使えるペインへ戻ってきたら、覚えている意図をもう一度当てる。 // 検索バーが出ている間だけ ── 閉じているのに本文が絞られるのは事故に見える。 if !searchBar.isHidden { applyRememberedFilter(to: v) } + // フィルタで置換が落ちたのが**なぜか**を言う(本人の指摘: 黙って落とすとバグに見える)。 + updateReadOnlyBanner() } /// 一致行の前後に出す行数を変える(検索バーの「±」欄・メニューの増減)。 @@ -1778,6 +1794,7 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { v.setCaseSensitive(true) v.setSearchQuery(value) v.setFilterMode(true) + refreshSearchBarCapabilities() } /// 時間帯で選んだ行だけを表示する(時間分布のドラッグ選択)。行は **0 始まり・昇順**。 @@ -1804,6 +1821,7 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { v.focusContent() } searchBar.clear() + updateReadOnlyBanner() // フィルタを解いたので、それで出ていたバナーも引っ込める } // MARK: - 開く @@ -1937,8 +1955,8 @@ extension MainWindowController: NSToolbarItemValidation, NSMenuDelegate { guard let v = activeViewer, v.supportsSearch, v.supportsSearchFilter else { NSSound.beep(); return } showSearch() searchBar.setFilterOn(true) - refreshSearchBarCapabilities() v.setFilterMode(true) + refreshSearchBarCapabilities() } @objc func toolbarSetStructuredMode(_ sender: Any?) { diff --git a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings index 0e3ad9c..d98165a 100644 --- a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings @@ -60,6 +60,8 @@ "doc.untitled" = "Untitled"; "sidebar.close" = "Close"; "readonly.banner" = "Read-only — this file is too large to edit."; +"readonly.banner.filter" = "Read-only — can't edit while showing matching lines only (filter)."; +"readonly.banner.jsonQuery" = "Read-only — can't edit while the JSON query is open."; /* Status bar */ "status.placeholder" = "Drag a file here, or press ⌘O to open"; diff --git a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings index 5d45d0d..b24eb48 100644 --- a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings @@ -60,6 +60,8 @@ "doc.untitled" = "名称未設定"; "sidebar.close" = "閉じる"; "readonly.banner" = "読み取り専用 — このファイルは大きすぎて編集できません。"; +"readonly.banner.filter" = "読み取り専用 — 一致行だけ表示(フィルタ)中は編集できません。"; +"readonly.banner.jsonQuery" = "読み取り専用 — JSON その場クエリ中は編集できません。"; /* ステータスバー */ "status.placeholder" = "ファイルをドラッグ、または ⌘O で開く"; diff --git a/Sources/MrEditorCore/UI/ReadOnlyBanner.swift b/Sources/MrEditorCore/UI/ReadOnlyBanner.swift index f05c376..00f97d5 100644 --- a/Sources/MrEditorCore/UI/ReadOnlyBanner.swift +++ b/Sources/MrEditorCore/UI/ReadOnlyBanner.swift @@ -8,6 +8,29 @@ final class ReadOnlyBanner: NSView { static let height: CGFloat = 26 + private let label = NSTextField(labelWithString: L("readonly.banner")) + + /// なぜ編集できないか。**理由によって文面を変える**──「大きすぎる」を、フィルタ中や + /// JSON その場クエリ中の小さなファイルにまで出すと、その場で嘘になって + /// 「バグでは」と誤解される(本人の指摘)。 + enum Reason { + case tooLarge + case filtered + case jsonQuery + + var messageKey: String { + switch self { + case .tooLarge: return "readonly.banner" + case .filtered: return "readonly.banner.filter" + case .jsonQuery: return "readonly.banner.jsonQuery" + } + } + } + + var reason: Reason = .tooLarge { + didSet { label.stringValue = L(reason.messageKey) } + } + override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() @@ -30,7 +53,6 @@ final class ReadOnlyBanner: NSView { icon.contentTintColor = .secondaryLabelColor icon.translatesAutoresizingMaskIntoConstraints = false - let label = NSTextField(labelWithString: L("readonly.banner")) label.font = .systemFont(ofSize: 11) label.textColor = .secondaryLabelColor label.translatesAutoresizingMaskIntoConstraints = false