From 0689a28cfb27dc376df4df86579a562b21a93fd4 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: Sat, 26 Sep 2026 23:20:31 +0900 Subject: [PATCH 1/5] =?UTF-8?q?metrics:=20views/clones=E3=81=AE=E3=83=88?= =?UTF-8?q?=E3=83=A9=E3=83=95=E3=82=A3=E3=83=83=E3=82=AF=E3=82=82=E6=97=A5?= =?UTF-8?q?=E6=AC=A1=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 既存のdownloads.csv記録の仕組みに、GitHub Traffic API(views/clones)を 同じ安全策(失敗したらCSVに触らない)で追記するステップを追加。 MrkAppReleaseのcollect-metrics.shと同様、Administration:read権限が 要るためTRAFFIC_TOKENを優先しGITHUB_TOKENへフォールバックする。 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/metrics.yml | 16 ++++++-- scripts/traffic_snapshot.sh | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 scripts/traffic_snapshot.sh diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index f743ec5..50dc168 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -1,4 +1,4 @@ -name: Snapshot download metrics +name: Snapshot download & traffic metrics # GitHub は「今の累計」しか返さないので、定期的に撮って差分を残す。 # 手元の Mac が起動していなくても回るように Actions で撮る。 @@ -42,17 +42,25 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: OUT=.metrics/downloads.csv sh scripts/metrics_snapshot.sh + # Traffic API は Administration:read が要る。GITHUB_TOKEN では取れないことがある + # ので TRAFFIC_TOKEN を優先し、未設定なら GITHUB_TOKEN にフォールバックする + # (その場合 gh api が失敗し、スクリプトの安全策で CSV には触らず落ちるだけ)。 + - name: views/clones を追記 + env: + GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN || secrets.GITHUB_TOKEN }} + run: OUT=.metrics/traffic.csv sh scripts/traffic_snapshot.sh + - name: 変化があれば metrics へコミット working-directory: .metrics run: | - if git diff --quiet -- downloads.csv; then + if git diff --quiet -- downloads.csv traffic.csv; then echo "変化なし。コミットしない。" exit 0 fi git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add downloads.csv - git commit -m "メトリクス: ダウンロード数のスナップショット ($(date -u +%Y-%m-%dT%H:%MZ))" + git add downloads.csv traffic.csv + git commit -m "メトリクス: ダウンロード数・トラフィックのスナップショット ($(date -u +%Y-%m-%dT%H:%MZ))" # 順番待ちの間に metrics が進んでいることがある。撮った行は捨てずに乗せ直す。 for i in 1 2 3; do git push origin HEAD:metrics && exit 0 diff --git a/scripts/traffic_snapshot.sh b/scripts/traffic_snapshot.sh new file mode 100644 index 0000000..995bf6d --- /dev/null +++ b/scripts/traffic_snapshot.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# GitHub Traffic API(views/clones)を、直近14日ローリング集計のスナップショットとして記録する。 +# +# downloads.csv(metrics_snapshot.sh)と違い、views/clones は「今の累計」ではなく +# 「直近14日間の集計」なので、前回より減っても異常ではない。よって減少チェックはしない。 +# 撮り続けること自体に意味がある(撮り漏らした日は API 側にも残らず復元できない)。 +# +# 正はブランチ `metrics`(main には無い。理由は metrics_snapshot.sh 参照)。 +# +# 使い方: +# sh scripts/traffic_snapshot.sh +# DRY_RUN=1 sh scripts/traffic_snapshot.sh # CSV に書かずに値だけ見る +# OUT=/tmp/x.csv sh scripts/traffic_snapshot.sh +# +# Traffic API は Administration:read 権限が要る。既定の GITHUB_TOKEN では取れないことが +# ある(MrkAppRelease で実際に token_required が発生した実績あり)ため、呼び出し側の +# ワークフローで secrets.TRAFFIC_TOKEN を優先し、無ければ GITHUB_TOKEN にフォールバックする。 +# +# 壊れた値を書かないための約束: +# - gh api が失敗したら書かない(パイプで握り潰さない)。 +set -eu + +REPO="${REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +DATA_BRANCH="${DATA_BRANCH:-metrics}" +OUT="${OUT:-}" +DRY_RUN="${DRY_RUN:-}" + +# OUT が指定されていなければ、正(ブランチ metrics)を作業コピーへ引いてくる。 +if [ -z "$OUT" ]; then + OUT=".metrics/traffic.csv" + mkdir -p "$(dirname "$OUT")" + if git rev-parse --git-dir >/dev/null 2>&1; then + git fetch -q origin "$DATA_BRANCH" 2>/dev/null || true + if git cat-file -e "FETCH_HEAD:traffic.csv" 2>/dev/null; then + git show "FETCH_HEAD:traffic.csv" > "$OUT" + echo "ブランチ $DATA_BRANCH から $OUT へ引いた(手元の結果は push しない)" + fi + fi +fi +AT="${AT:-$(date -u +%Y-%m-%dT%H:%MZ)}" + +mkdir -p "$(dirname "$OUT")" +[ -f "$OUT" ] || printf 'taken_at,views_14d,unique_views_14d,clones_14d,unique_clones_14d\n' > "$OUT" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if ! views_json="$(gh api "repos/$REPO/traffic/views" 2>"$WORK/err")"; then + echo "views の取得に失敗した。CSV は変更していない。" >&2 + cat "$WORK/err" >&2 + exit 1 +fi +if ! clones_json="$(gh api "repos/$REPO/traffic/clones" 2>"$WORK/err")"; then + echo "clones の取得に失敗した。CSV は変更していない。" >&2 + cat "$WORK/err" >&2 + exit 1 +fi + +views="$(printf '%s' "$views_json" | jq -r '.count')" +unique_views="$(printf '%s' "$views_json" | jq -r '.uniques')" +clones="$(printf '%s' "$clones_json" | jq -r '.count')" +unique_clones="$(printf '%s' "$clones_json" | jq -r '.uniques')" + +if [ -z "$DRY_RUN" ]; then + grep -v "^\"$AT\"," "$OUT" > "$WORK/out" 2>/dev/null || true + printf '"%s",%s,%s,%s,%s\n' "$AT" "$views" "$unique_views" "$clones" "$unique_clones" >> "$WORK/out" + mv "$WORK/out" "$OUT" +fi + +printf '%s views=%s(uniq %s) clones=%s(uniq %s)' "$AT" "$views" "$unique_views" "$clones" "$unique_clones" +if [ -n "$DRY_RUN" ]; then + printf ' [DRY_RUN: CSV は書いていない]' +fi +printf '\n' From f140cbe8bbc7308ddc0b957622699337958187f8 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: Sun, 27 Sep 2026 10:07:28 +0900 Subject: [PATCH 2/5] =?UTF-8?q?core:=20=E4=BB=96=E3=83=89=E3=83=A1?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=8B=E3=82=89=E8=A8=AD=E5=AE=9A=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=82=80=E5=8F=A3=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88?= =?UTF-8?q?Pro=E5=B0=82=E7=94=A8=E6=A9=9F=E8=83=BD=E3=81=AE=E4=B8=8B?= =?UTF-8?q?=E3=81=94=E3=81=97=E3=82=89=E3=81=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppSettings/EditorTheme/EditorFontの各getterを「自ドメイン向けcomputed var」と 「任意のUserDefaultsから読む関数」に分離し、FreeSettingsImportから同じ復号ロジックを 再利用できるようにした。無料版のメニュー・実行ファイルはFreeSettingsImportを一切 参照しないため、Pro専用の機能でありながらPro.allows()のゲートは不要(check_consistency.py のpro_gate検査でも確認済み)。 Co-Authored-By: Claude Sonnet 5 --- Sources/MrEditorCore/AppSettings.swift | 83 ++++++++++++-- .../Core/FreeSettingsImport.swift | 106 ++++++++++++++++++ Sources/MrEditorCore/EditorFont.swift | 8 ++ Sources/MrEditorCore/EditorTheme.swift | 52 +++++++-- 4 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 Sources/MrEditorCore/Core/FreeSettingsImport.swift diff --git a/Sources/MrEditorCore/AppSettings.swift b/Sources/MrEditorCore/AppSettings.swift index aef7c7b..fa98235 100644 --- a/Sources/MrEditorCore/AppSettings.swift +++ b/Sources/MrEditorCore/AppSettings.swift @@ -164,22 +164,29 @@ enum AppSettings { } static var saveProgressStyle: SaveProgressStyle { - get { SaveProgressStyle(rawValue: defaults.string(forKey: saveProgressKey) ?? "") ?? .sheet } + get { saveProgressStyle(from: defaults) } set { defaults.set(newValue.rawValue, forKey: saveProgressKey) } } + private static func saveProgressStyle(from source: UserDefaults) -> SaveProgressStyle { + SaveProgressStyle(rawValue: source.string(forKey: saveProgressKey) ?? "") ?? .sheet + } /// 長い行を折り返すか。false=折り返さず横スクロール(既定)、true=内容幅で折り返す。 static var lineWrap: Bool { - get { defaults.bool(forKey: lineWrapKey) } + get { lineWrap(from: defaults) } set { defaults.set(newValue, forKey: lineWrapKey); NotificationCenter.default.post(name: .mrEditorLineWrapChanged, object: nil) } } + private static func lineWrap(from source: UserDefaults) -> Bool { source.bool(forKey: lineWrapKey) } /// 一致行だけ表示のとき、前後に足す行数(`grep -C` 相当)。既定 0。 /// ファイルごとではなくアプリの設定として覚える(一度決めたら次のファイルでも同じ見え方になる)。 static var filterContextLines: Int { - get { min(max(0, defaults.integer(forKey: filterContextKey)), FilterContext.maxContext) } + get { filterContextLines(from: defaults) } set { defaults.set(min(max(0, newValue), FilterContext.maxContext), forKey: filterContextKey) } } + private static func filterContextLines(from source: UserDefaults) -> Int { + min(max(0, source.integer(forKey: filterContextKey)), FilterContext.maxContext) + } /// ⌘F を「一致行だけ表示」で開くか。**漏斗を最後に自分で操作した状態**を覚える。 /// @@ -188,46 +195,63 @@ enum AppSettings { /// (構造化・JSON)へ移ると強制的に off になり、戻ってきても off のままだった。 /// 覚えるのは**意図**で、使えるかどうかはペインが決める。既定 false(従来どおり)。 static var searchFilterOn: Bool { - get { defaults.bool(forKey: searchFilterKey) } + get { searchFilterOn(from: defaults) } set { defaults.set(newValue, forKey: searchFilterKey) } } + private static func searchFilterOn(from source: UserDefaults) -> Bool { source.bool(forKey: searchFilterKey) } /// タブの表示幅(文字数)。既定 4。選択肢は 2/4/8。 static var tabWidth: Int { - get { let v = defaults.integer(forKey: tabWidthKey); return v > 0 ? v : 4 } + get { tabWidth(from: defaults) } set { defaults.set(newValue, forKey: tabWidthKey); postDisplayChanged() } } + private static func tabWidth(from source: UserDefaults) -> Int { + let v = source.integer(forKey: tabWidthKey); return v > 0 ? v : 4 + } /// 行間。 static var lineSpacing: LineSpacing { - get { LineSpacing(rawValue: defaults.string(forKey: lineSpacingKey) ?? "") ?? .standard } + get { lineSpacing(from: defaults) } set { defaults.set(newValue.rawValue, forKey: lineSpacingKey); postDisplayChanged() } } + private static func lineSpacing(from source: UserDefaults) -> LineSpacing { + LineSpacing(rawValue: source.string(forKey: lineSpacingKey) ?? "") ?? .standard + } /// キャレット行を淡い帯で強調するか。既定 true。 static var highlightCurrentLine: Bool { - get { defaults.object(forKey: highlightCurrentLineKey) as? Bool ?? true } + get { highlightCurrentLine(from: defaults) } set { defaults.set(newValue, forKey: highlightCurrentLineKey); postDisplayChanged() } } + private static func highlightCurrentLine(from source: UserDefaults) -> Bool { + source.object(forKey: highlightCurrentLineKey) as? Bool ?? true + } /// 行番号(ガター)を表示するか。既定 true。 /// 巨大ファイル側は自前ガター、小ファイル側は NSRulerView が担うが、設定は 1 つ。 static var showLineNumbers: Bool { - get { defaults.object(forKey: showLineNumbersKey) as? Bool ?? true } + get { showLineNumbers(from: defaults) } set { defaults.set(newValue, forKey: showLineNumbersKey); postDisplayChanged() } } + private static func showLineNumbers(from source: UserDefaults) -> Bool { + source.object(forKey: showLineNumbersKey) as? Bool ?? true + } /// 不可視文字(タブ・改行・全角スペース・行末の半角スペース)を記号で見せるか。既定 false。 static var showInvisibles: Bool { - get { defaults.bool(forKey: showInvisiblesKey) } + get { showInvisibles(from: defaults) } set { defaults.set(newValue, forKey: showInvisiblesKey); postDisplayChanged() } } + private static func showInvisibles(from source: UserDefaults) -> Bool { source.bool(forKey: showInvisiblesKey) } /// キャレット形状。 static var cursorShape: CursorShape { - get { CursorShape(rawValue: defaults.string(forKey: cursorShapeKey) ?? "") ?? .bar } + get { cursorShape(from: defaults) } set { defaults.set(newValue.rawValue, forKey: cursorShapeKey); postDisplayChanged() } } + private static func cursorShape(from source: UserDefaults) -> CursorShape { + CursorShape(rawValue: source.string(forKey: cursorShapeKey) ?? "") ?? .bar + } /// 前回終了時のセッション(左サイドバーの並び順・アクティブ位置)。次回起動時に復元する。 static var session: SessionState? { @@ -314,9 +338,12 @@ enum AppSettings { /// **オフでも変更は知らせる**(本文下端のバナー)。黙って古い内容を見せ続けることはしない。 /// 未保存の変更があるときは、オンでも自動では取り込まない(潰してしまうため)。 static var autoReloadExternalChanges: Bool { - get { defaults.object(forKey: autoReloadKey) as? Bool ?? true } + get { autoReloadExternalChanges(from: defaults) } set { defaults.set(newValue, forKey: autoReloadKey) } } + private static func autoReloadExternalChanges(from source: UserDefaults) -> Bool { + source.object(forKey: autoReloadKey) as? Bool ?? true + } /// 起動時に新しい版が出ていないか自動で調べるか。既定 true。 /// App Store 配布ではないため、これが無いと利用者は新版に気づけない。 @@ -392,6 +419,40 @@ enum AppSettings { private static func postDisplayChanged() { NotificationCenter.default.post(name: .mrEditorDisplayChanged, object: nil) } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// [[FreeSettingsImport]] が運ぶ、外観を除いた挙動設定の値だけの束。 + struct BehaviorSnapshot { + var saveProgressStyle: SaveProgressStyle + var lineWrap: Bool + var filterContextLines: Int + var searchFilterOn: Bool + var tabWidth: Int + var lineSpacing: LineSpacing + var highlightCurrentLine: Bool + var showLineNumbers: Bool + var showInvisibles: Bool + var cursorShape: CursorShape + var autoReloadExternalChanges: Bool + } + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。**書き込みはしない。** + /// セッション・AI 設定・更新チェックはここに含まれない([[FreeSettingsImport]] のコメント参照)。 + static func behaviorSnapshot(from source: UserDefaults) -> BehaviorSnapshot { + BehaviorSnapshot( + saveProgressStyle: saveProgressStyle(from: source), + lineWrap: lineWrap(from: source), + filterContextLines: filterContextLines(from: source), + searchFilterOn: searchFilterOn(from: source), + tabWidth: tabWidth(from: source), + lineSpacing: lineSpacing(from: source), + highlightCurrentLine: highlightCurrentLine(from: source), + showLineNumbers: showLineNumbers(from: source), + showInvisibles: showInvisibles(from: source), + cursorShape: cursorShape(from: source), + autoReloadExternalChanges: autoReloadExternalChanges(from: source)) + } } extension Notification.Name { diff --git a/Sources/MrEditorCore/Core/FreeSettingsImport.swift b/Sources/MrEditorCore/Core/FreeSettingsImport.swift new file mode 100644 index 0000000..45fabf8 --- /dev/null +++ b/Sources/MrEditorCore/Core/FreeSettingsImport.swift @@ -0,0 +1,106 @@ +import AppKit + +/// 同じ Mac にインストールされた無料版(MrEditor)から、Pro 側の設定へ安全に取り込む。 +/// +/// **Pro 専用の機能。** 呼び出し口は Pro 側(`MrkPro`)にしか存在しない。core 自身のメニュー・ +/// 実行ファイルはこの型を一切参照しないので、無料版に機能が漏れることはない +/// (`scripts/check_consistency.py` の pro_gate 検査は「core 内で `ProFeature` の +/// ケースを未ゲートで使っていないか」を見るものだが、この型はそもそも `ProFeature` の +/// ケースではないので対象外 —— ゲートの必要が無い形にしてある)。 +/// +/// 意図的に含めないもの: +/// - セッション・下書き([[AppSettings.session]])… 開いていたファイルの復元情報 +/// - ファイルごとの列定義(`MrEditor.columnFields`)… 絶対パスをキーに持つため +/// - 自動更新チェックの設定・最終チェック時刻 … アプリごとに更新経路が別 +/// - AI プロバイダ設定 … 既に Keychain 共有アカウントで解決済み([[AppSettings.aiConfig]])。 +/// ここでも UserDefaults 経由で運ぶと、真実の源が二重になる +/// - 秘密情報(API キー等) … そもそも UserDefaults には無く Keychain にしかない +public struct FreeSettingsImport: Equatable { + // 外観([[EditorTheme]] / [[EditorFont]]) + public var themePresetRawValue: String + /// `EditorTheme.ColorKey.rawValue` → 色。 + public var customColors: [String: NSColor] + public var backgroundOpacity: Double + public var ansiColors: Bool + public var fontName: String? + public var fontSize: Double + + // 挙動([[AppSettings]]) + public var saveProgressStyleRawValue: String + public var lineWrap: Bool + public var filterContextLines: Int + public var searchFilterOn: Bool + public var tabWidth: Int + public var lineSpacingRawValue: String + public var highlightCurrentLine: Bool + public var showLineNumbers: Bool + public var showInvisibles: Bool + public var cursorShapeRawValue: String + public var autoReloadExternalChanges: Bool + + /// 指定したバンドル ID の UserDefaults ドメインから読む。 + /// そのアプリが一度も起動されていない等でドメインが空なら nil。 + public static func capture(fromBundleID bundleID: String) -> FreeSettingsImport? { + guard let source = UserDefaults(suiteName: bundleID), + let domain = source.persistentDomain(forName: bundleID), !domain.isEmpty else { return nil } + + let theme = EditorTheme.appearanceSnapshot(from: source) + let font = EditorFont.snapshot(from: source) + let behavior = AppSettings.behaviorSnapshot(from: source) + + return FreeSettingsImport( + themePresetRawValue: theme.preset.rawValue, + customColors: theme.customColors, + backgroundOpacity: Double(theme.backgroundOpacity), + ansiColors: theme.ansiColorsEnabled, + fontName: font.name, + fontSize: Double(font.size), + saveProgressStyleRawValue: behavior.saveProgressStyle.rawValue, + lineWrap: behavior.lineWrap, + filterContextLines: behavior.filterContextLines, + searchFilterOn: behavior.searchFilterOn, + tabWidth: behavior.tabWidth, + lineSpacingRawValue: behavior.lineSpacing.rawValue, + highlightCurrentLine: behavior.highlightCurrentLine, + showLineNumbers: behavior.showLineNumbers, + showInvisibles: behavior.showInvisibles, + cursorShapeRawValue: behavior.cursorShape.rawValue, + autoReloadExternalChanges: behavior.autoReloadExternalChanges) + } + + /// 自分自身(呼び出し側アプリ)のグローバル設定へ適用する。 + /// 既存の setter 経由なので、開いているウィンドウへの通知も既存の仕組みでそのまま飛ぶ。 + public func apply() { + // custom 色を先に入れると preset が一旦 .custom に倒れるため、preset は最後に確定する + // ([[SettingsBundle.apply]] と同じ順序)。 + for key in EditorTheme.ColorKey.allCases { + if let color = customColors[key.rawValue] { + EditorTheme.setCustomColor(key, color) + } + } + EditorTheme.preset = ThemePreset(rawValue: themePresetRawValue) ?? .system + EditorTheme.backgroundOpacity = CGFloat(backgroundOpacity) + EditorTheme.ansiColorsEnabled = ansiColors + EditorFont.setName(fontName) + EditorFont.setSize(CGFloat(fontSize)) + + AppSettings.saveProgressStyle = SaveProgressStyle(rawValue: saveProgressStyleRawValue) ?? .sheet + AppSettings.lineWrap = lineWrap + AppSettings.filterContextLines = filterContextLines + AppSettings.searchFilterOn = searchFilterOn + AppSettings.tabWidth = tabWidth + AppSettings.lineSpacing = LineSpacing(rawValue: lineSpacingRawValue) ?? .standard + AppSettings.highlightCurrentLine = highlightCurrentLine + AppSettings.showLineNumbers = showLineNumbers + AppSettings.showInvisibles = showInvisibles + AppSettings.cursorShape = CursorShape(rawValue: cursorShapeRawValue) ?? .bar + AppSettings.autoReloadExternalChanges = autoReloadExternalChanges + } + + /// 確認シート用の短い説明([[SettingsBundle.summaryLines]] と同じ見せ方)。 + public func summaryLines() -> [String] { + let themeName = L("prefs.theme.\(themePresetRawValue)") + let fontDesc = (fontName ?? L("prefs.font.system")) + " \(Int(fontSize)) pt" + return ["\(L("prefs.theme")): \(themeName)", "\(L("prefs.font")): \(fontDesc)"] + } +} diff --git a/Sources/MrEditorCore/EditorFont.swift b/Sources/MrEditorCore/EditorFont.swift index 7d07e7f..b88c697 100644 --- a/Sources/MrEditorCore/EditorFont.swift +++ b/Sources/MrEditorCore/EditorFont.swift @@ -62,6 +62,14 @@ enum EditorFont { } return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。書き込みはしない。 + static func snapshot(from source: UserDefaults) -> (name: String?, size: CGFloat) { + let v = source.double(forKey: sizeKey) + return (source.string(forKey: nameKey), v > 0 ? CGFloat(v) : defaultSize) + } } extension Notification.Name { diff --git a/Sources/MrEditorCore/EditorTheme.swift b/Sources/MrEditorCore/EditorTheme.swift index 9c3914a..6e621b6 100644 --- a/Sources/MrEditorCore/EditorTheme.swift +++ b/Sources/MrEditorCore/EditorTheme.swift @@ -68,22 +68,26 @@ enum EditorTheme { // MARK: - プリセット選択 static var preset: ThemePreset { - get { ThemePreset(rawValue: defaults.string(forKey: presetKey) ?? "") ?? .system } + get { preset(from: defaults) } set { defaults.set(newValue.rawValue, forKey: presetKey); postChanged() } } + private static func preset(from source: UserDefaults) -> ThemePreset { + ThemePreset(rawValue: source.string(forKey: presetKey) ?? "") ?? .system + } // MARK: - 背景の不透明度(ウィンドウ全体・iTerm 風) /// 本文+周辺 UI の背景を透かす度合い。1.0=完全不透明(既定・従来と同一)、 /// 0.30 まで下げられる。透明時は窓を非不透明にして背後のデスクトップを見せる。 static var backgroundOpacity: CGFloat { - get { - guard defaults.object(forKey: opacityKey) != nil else { return 1.0 } - let v = CGFloat(defaults.double(forKey: opacityKey)) - return min(1.0, max(0.30, v)) - } + get { backgroundOpacity(from: defaults) } set { defaults.set(Double(min(1.0, max(0.30, newValue))), forKey: opacityKey); postChanged() } } + private static func backgroundOpacity(from source: UserDefaults) -> CGFloat { + guard source.object(forKey: opacityKey) != nil else { return 1.0 } + let v = CGFloat(source.double(forKey: opacityKey)) + return min(1.0, max(0.30, v)) + } /// 背景が完全不透明か(不透明度 1.0)。透明プラミングの有効化判定に使う。 static var isOpaqueBackground: Bool { backgroundOpacity >= 0.999 } @@ -101,9 +105,12 @@ enum EditorTheme { /// ログ中の ANSI SGR エスケープ(`ESC[…m`)を色に変換して表示するか。 /// 既定 ON(生のエスケープ列は可読でないため)。閲覧経路でのみ適用する。 static var ansiColorsEnabled: Bool { - get { defaults.object(forKey: ansiKey) == nil ? true : defaults.bool(forKey: ansiKey) } + get { ansiColorsEnabled(from: defaults) } set { defaults.set(newValue, forKey: ansiKey); postChanged() } } + private static func ansiColorsEnabled(from source: UserDefaults) -> Bool { + source.object(forKey: ansiKey) == nil ? true : source.bool(forKey: ansiKey) + } // MARK: - 現在の配色 @@ -182,8 +189,10 @@ enum EditorTheme { } /// custom の 1 色を読む。未設定なら system プリセットの対応色にフォールバック。 - static func customColor(_ key: ColorKey) -> NSColor { - if let data = defaults.data(forKey: customPrefix + key.rawValue), + static func customColor(_ key: ColorKey) -> NSColor { customColor(key, from: defaults) } + + private static func customColor(_ key: ColorKey, from source: UserDefaults) -> NSColor { + if let data = source.data(forKey: customPrefix + key.rawValue), let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: data) { return color } @@ -249,4 +258,29 @@ enum EditorTheme { private static func postChanged() { NotificationCenter.default.post(name: .mrEditorDisplayChanged, object: nil) } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// [[FreeSettingsImport]] が運ぶ、外観設定の値だけの束。書き込みはしない。 + struct AppearanceSnapshot { + var preset: ThemePreset + /// `ColorKey.rawValue` → 色。custom プリセットでなくても常に埋めて返す + /// (後から custom に切り替えても迷わないように)。 + var customColors: [String: NSColor] + var backgroundOpacity: CGFloat + var ansiColorsEnabled: Bool + } + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。 + static func appearanceSnapshot(from source: UserDefaults) -> AppearanceSnapshot { + var colors: [String: NSColor] = [:] + for key in ColorKey.allCases { + colors[key.rawValue] = customColor(key, from: source) + } + return AppearanceSnapshot( + preset: preset(from: source), + customColors: colors, + backgroundOpacity: backgroundOpacity(from: source), + ansiColorsEnabled: ansiColorsEnabled(from: source)) + } } From b5f3a0223ec0b40b38ce78229f778f35261cafd5 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: Mon, 28 Sep 2026 21:18:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E3=83=91=E3=82=B9=E3=82=92=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E3=81=97=E3=81=A6=E9=96=8B=E3=81=8F=E2=80=A6=EF=BC=88?= =?UTF-8?q?=E2=8C=A5=E2=8C=98O=EF=BC=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コピーしたローカルパスを貼り付けて開く小さな窓。前後の空白・引用符を落とし ~を展開してから存在確認する。ファイルはエディタで開き、フォルダを開く仕組みは MrEditorに無いのでFinderで見せるところまでで済ませる。相対パスは非対応 (GUIアプリのcwdは基準にならないため)。 Co-Authored-By: Claude Sonnet 5 --- Sources/MrEditorCore/AppDelegate.swift | 17 +++ .../Resources/en.lproj/Localizable.strings | 8 ++ .../Resources/ja.lproj/Localizable.strings | 8 ++ .../UI/PathOpenWindowController.swift | 105 ++++++++++++++++++ .../PathOpenWindowControllerTests.swift | 58 ++++++++++ 5 files changed, 196 insertions(+) create mode 100644 Sources/MrEditorCore/UI/PathOpenWindowController.swift create mode 100644 Tests/MrEditorTests/PathOpenWindowControllerTests.swift diff --git a/Sources/MrEditorCore/AppDelegate.swift b/Sources/MrEditorCore/AppDelegate.swift index a23b35f..b34d1b8 100644 --- a/Sources/MrEditorCore/AppDelegate.swift +++ b/Sources/MrEditorCore/AppDelegate.swift @@ -17,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu /// 開いた遠隔の面。持っておかないと即座に閉じる(NSWindowController は自分を保持しない)。 private var remoteWindows: [RemoteWindowController] = [] private var preferencesController: PreferencesWindowController? + private var pathOpenController: PathOpenWindowController? func applicationDidFinishLaunching(_ notification: Notification) { // 開発ビルド(バンドル無し)でも Dock・About でアプリアイコンを出す。 @@ -342,6 +343,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu controller.window?.makeKeyAndOrderFront(nil) } + /// ターミナルや AI の回答からコピーしたパスを貼り付けて開く(⌥⌘O)。 + @objc private func openByPath(_ sender: Any?) { + if pathOpenController == nil { + pathOpenController = PathOpenWindowController(openHandler: { [weak self] url in + self?.ensureController().open(url: url) + }) + } + pathOpenController?.show() + } + /// File > 最近使った項目/Edit > クリップボード履歴、サブメニューを開くたびに再構築する。 func menuNeedsUpdate(_ menu: NSMenu) { if menu === recentMenu { updateRecentMenu(menu); return } @@ -607,6 +618,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenu remoteItem.keyEquivalentModifierMask = [.command, .control] remoteItem.target = self fileMenu.addItem(remoteItem) + // ターミナルや AI の回答からコピーしたパスを貼り付けて開く。 + let pathItem = NSMenuItem(title: L("menu.openByPath"), + action: #selector(openByPath(_:)), keyEquivalent: "o") + pathItem.keyEquivalentModifierMask = [.command, .option] + pathItem.target = self + fileMenu.addItem(pathItem) // 最近使った項目(サブメニューは開くたびに menuNeedsUpdate で再構築) let recentItem = NSMenuItem(title: L("menu.openRecent"), action: nil, keyEquivalent: "") let recent = NSMenu(title: L("menu.openRecent")) diff --git a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings index ec186ed..cf98fbd 100644 --- a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings @@ -206,6 +206,12 @@ "prefs.share.copied" = "Share link copied to clipboard."; "prefs.share.imported" = "Settings applied."; "prefs.share.export.defaultName" = "MrEditor Theme"; + +/* Open by path */ +"pathOpen.title" = "Open by Path"; +"pathOpen.placeholder" = "Path to a file or folder"; +"pathOpen.open" = "Open"; +"pathOpen.notFound" = "That path could not be found"; "share.apply.title" = "Apply these appearance settings?"; "share.apply.confirm" = "Apply"; "share.import.failed.title" = "Couldn’t read the settings"; @@ -384,6 +390,8 @@ "menu.nextBookmark" = "Next Bookmark"; "menu.prevBookmark" = "Previous Bookmark"; +"menu.openByPath" = "Open by Path…"; + /* --- Remote (B5) --- */ "menu.openRemote" = "Open Remote…"; "remote.title" = "Remote"; diff --git a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings index 8739e8f..1ae2272 100644 --- a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings @@ -206,6 +206,12 @@ "prefs.share.copied" = "共有リンクをクリップボードにコピーしました。"; "prefs.share.imported" = "設定を適用しました。"; "prefs.share.export.defaultName" = "MrEditor テーマ"; + +/* パスを指定して開く */ +"pathOpen.title" = "パスを指定して開く"; +"pathOpen.placeholder" = "ファイルまたはフォルダのパス"; +"pathOpen.open" = "開く"; +"pathOpen.notFound" = "そのパスが見つかりません"; "share.apply.title" = "この外観設定を適用しますか?"; "share.apply.confirm" = "適用"; "share.import.failed.title" = "設定を読み込めませんでした"; @@ -384,6 +390,8 @@ "menu.nextBookmark" = "次のしおりへ"; "menu.prevBookmark" = "前のしおりへ"; +"menu.openByPath" = "パスを指定して開く…"; + /* --- リモート(B5) --- */ "menu.openRemote" = "リモートを開く…"; "remote.title" = "リモート"; diff --git a/Sources/MrEditorCore/UI/PathOpenWindowController.swift b/Sources/MrEditorCore/UI/PathOpenWindowController.swift new file mode 100644 index 0000000..81ea25d --- /dev/null +++ b/Sources/MrEditorCore/UI/PathOpenWindowController.swift @@ -0,0 +1,105 @@ +import AppKit + +/// 「パスを指定して開く…」(⌥⌘O)。ターミナルや AI の回答からコピーしたローカルパスを +/// 貼り付けて開くための小さな窓。狙いは独自性ではなく、パスを迷わず開ける使いやすさ。 +/// +/// **フォルダを開く仕組みは MrEditor に無い**(サイドバーのファイルツリー等を持たない)。 +/// フォルダが渡されたら、独自の閲覧 UI は作らず Finder で見せるだけにする。 +final class PathOpenWindowController: NSWindowController { + + private let pathField = NSTextField() + private let errorLabel = NSTextField(labelWithString: "") + private let openHandler: (URL) -> Void + + init(openHandler: @escaping (URL) -> Void) { + self.openHandler = openHandler + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 110), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = L("pathOpen.title") + window.isReleasedWhenClosed = false + super.init(window: window) + window.center() + buildLayout() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) は使わない") } + + /// ウィンドウを最前面に出す(無ければ生成済みのものを再利用)。呼ぶたびに入力欄を空にする。 + func show() { + pathField.stringValue = "" + errorLabel.stringValue = "" + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + window?.makeFirstResponder(pathField) + NSApp.activate(ignoringOtherApps: true) + } + + private func buildLayout() { + guard let content = window?.contentView else { return } + + pathField.placeholderString = L("pathOpen.placeholder") + pathField.target = self + pathField.action = #selector(openTapped) + pathField.translatesAutoresizingMaskIntoConstraints = false + pathField.widthAnchor.constraint(greaterThanOrEqualToConstant: 340).isActive = true + + let openButton = NSButton(title: L("pathOpen.open"), target: self, action: #selector(openTapped)) + openButton.keyEquivalent = "\r" + + errorLabel.textColor = .systemRed + errorLabel.font = .systemFont(ofSize: 11) + + let row = NSStackView(views: [pathField, openButton]) + row.orientation = .horizontal + row.spacing = 8 + + let stack = NSStackView(views: [row, errorLabel]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -20), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), + ]) + } + + @objc private func openTapped() { + guard let url = Self.resolve(pathField.stringValue) else { + errorLabel.stringValue = L("pathOpen.notFound") + return + } + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + if isDir.boolValue { + // 開く先が無いので、Finder で見せるところまでで済ませる。 + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else { + openHandler(url) + } + window?.performClose(nil) + } + + /// 入力文字列を実在するファイル/フォルダの URL にする。無ければ nil。 + /// 前後の空白と、コピー時に付く外側の引用符(1 組だけ)を落とし、`~` を展開する。 + /// 相対パスは対応しない(GUI アプリの cwd は実用上 `/` になるだけで、基準にならない)。 + static func resolve(_ raw: String) -> URL? { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if s.count >= 2 { + let pairs: [(Character, Character)] = [("\"", "\""), ("'", "'"), ("\u{201C}", "\u{201D}")] + for (open, close) in pairs where s.first == open && s.last == close { + s = String(s.dropFirst().dropLast()) + break + } + } + s = (s as NSString).expandingTildeInPath + guard !s.isEmpty, FileManager.default.fileExists(atPath: s) else { return nil } + return URL(fileURLWithPath: s) + } +} diff --git a/Tests/MrEditorTests/PathOpenWindowControllerTests.swift b/Tests/MrEditorTests/PathOpenWindowControllerTests.swift new file mode 100644 index 0000000..a63da69 --- /dev/null +++ b/Tests/MrEditorTests/PathOpenWindowControllerTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import MrEditorCore + +/// パス解決(引用符除去・`~`展開・存在確認)の純粋な部分を検証する。 +final class PathOpenWindowControllerTests: XCTestCase { + + private var tempFile: URL! + private var tempDir: URL! + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + tempFile = tempDir.appendingPathComponent("a.txt") + try Data("hello".utf8).write(to: tempFile) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + func testResolvesPlainExistingFile() { + XCTAssertEqual(PathOpenWindowController.resolve(tempFile.path), tempFile) + } + + func testTrimsSurroundingWhitespace() { + XCTAssertEqual(PathOpenWindowController.resolve(" \(tempFile.path) "), tempFile) + } + + func testStripsOneSurroundingPairOfStraightQuotes() { + XCTAssertEqual(PathOpenWindowController.resolve("\"\(tempFile.path)\""), tempFile) + } + + func testStripsOneSurroundingPairOfSingleQuotes() { + XCTAssertEqual(PathOpenWindowController.resolve("'\(tempFile.path)'"), tempFile) + } + + func testDoesNotStripUnbalancedQuote() { + XCTAssertNil(PathOpenWindowController.resolve("\"\(tempFile.path)")) + } + + func testExpandsTilde() { + let home = FileManager.default.homeDirectoryForCurrentUser + XCTAssertEqual(PathOpenWindowController.resolve("~"), home) + } + + func testResolvesExistingDirectory() { + // フォルダは `URL(fileURLWithPath:)` が末尾に `/` を付けるので、パス文字列で比べる。 + XCTAssertEqual(PathOpenWindowController.resolve(tempDir.path)?.path, tempDir.path) + } + + func testReturnsNilForMissingPath() { + XCTAssertNil(PathOpenWindowController.resolve(tempDir.appendingPathComponent("does-not-exist").path)) + } + + func testReturnsNilForEmptyInput() { + XCTAssertNil(PathOpenWindowController.resolve(" ")) + } +} From bc968d2f5b18349c122db8146d815a8c43e1f04f 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: Mon, 28 Sep 2026 21:44:29 +0900 Subject: [PATCH 4/5] =?UTF-8?q?1.19.0:=20=E7=89=88=E4=B8=8A=E3=81=92?= =?UTF-8?q?=E3=81=A8=E6=96=87=E6=9B=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit パスを指定して開く…(⌥⌘O、b5f3a02)の版文字列とリリース文書を更新。 AppInfo.fallbackVersion / make_app.sh / make_dmg.sh / LPバッジ・DLリンクを 1.18.0→1.19.0に統一。README(日英)・リリース全史(web/releases.src.html)に 1.19.0の行/eraを追加。前版1.18.0の「✅(このリリース)」は「✅(1.18.0)」へ降格。 build_site.pyでsite/を再生成。 一貫性チェッカ ✅ 全項目通過。 Co-Authored-By: Claude Sonnet 5 --- .consistency.json | 6 ++-- README.ja.md | 11 +++++--- README.md | 11 +++++--- Sources/MrEditorCore/AppInfo.swift | 2 +- scripts/make_app.sh | 2 +- scripts/make_dmg.sh | 2 +- site/index.en.html | 39 +++++++++++++++++++++----- site/index.html | 39 +++++++++++++++++++++----- site/index.ja.html | 39 +++++++++++++++++++++----- site/releases.html | 22 +++++++++++---- site/releases.ja.html | 22 +++++++++++---- web/lp.src.html | 44 ++++++++++++++++++++++++------ web/releases.src.html | 34 ++++++++++++++++------- 13 files changed, 210 insertions(+), 63 deletions(-) diff --git a/.consistency.json b/.consistency.json index a89f7ec..ad2c9ec 100644 --- a/.consistency.json +++ b/.consistency.json @@ -50,7 +50,8 @@ "lang_parity": [ "web/lp.src.html", "web/releases.src.html", - "web/download.src.html" + "web/download.src.html", + "web/mrkeditor.src.html" ], "i18n": { "table": "Sources/MrEditorCore/Resources/{lang}.lproj/Localizable.strings", @@ -101,7 +102,8 @@ "README.ja.md", "README.md", "web/lp.src.html", - "web/releases.src.html" + "web/releases.src.html", + "web/mrkeditor.src.html" ], "pro_gate": { "seam": "Sources/MrEditorCore/Pro/Pro.swift", diff --git a/README.ja.md b/README.ja.md index ff42840..63039b8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -272,7 +272,7 @@ python3 scripts/gen_testdata.py --encoding-set --out-dir testdata/ # UTF-8 / S python3 scripts/gen_testdata.py --size 10G --jp --out testdata/test_10gb.log ``` -配布用ディスクイメージ(`.build/MrEditor-1.18.0.dmg`)の作成: +配布用ディスクイメージ(`.build/MrEditor-1.19.0.dmg`)の作成: ```sh sh scripts/make_dmg.sh @@ -362,7 +362,8 @@ vmmap $(pgrep -x MrEditor) | grep test_10gb.log # → 10.0G 2.8G 0K (vsiz - **1.15.0 — 日本語の CSV が重かった。** 日本語の CSV は Shift-JIS・CRLF・引用符つきで届く。その検索が **1 行ずつ `String` へ変換してから照合していて、1.06GB・581万行で 26.2 秒**かかっていた。release ビルドにしても縮まない —— 自分のコードではなく、581万回の文字列生成そのものが費用だったから。**探す語のほうを Shift-JIS のバイト列にして、直接走査するようにした。2.4 秒(11 倍)。**2 バイト目が ASCII と重なるので、当たった位置が文字の切れ目に乗っているかを確かめて誤爆を弾く。あわせて **検索バーが「終わった」を出す** ようになった —— 走査中は件数と進み具合が一緒に動き、終わると進み具合が消える。前は 1 件当たった瞬間に件数だけになり、まだ増え続けるのに確定したように見えていた ✅(1.15.0) - **1.16.0 — 閉じるタブを1つ間違えると、戻り道はファイル ▸ 開く…でパスを記憶から打ち直すことだけだった。** **`⇧⌘T` で、いま閉じたファイルを開き直せる**ようにした。閉じたドキュメントはスタックで持ち(直近が末尾)、押すたびに1件 pop して開く——押し続ければどんどん前まで戻れる。保存先が確定していない新規ドキュメント(まだ「名称未設定」のまま)は積まない。ディスク上に開き直す先が無いため ✅(1.16.0) - **1.17.0 — 新しくコピーすると、直前のコピーは消える。** 貼り付け先を間違えたとき、戻る方法がコピー元のアプリを開き直す以外に無かった。**編集メニュー→クリップボード履歴**(ツールバーにも任意で追加できる)に直近のコピーを並べ、選べばそのまま貼り付く。**ディスクには残さず**、1Password 等が付ける「見せない」の印(`org.nspasteboard.ConcealedType`)が立ったコピーは記録しない ✅(1.17.0) -- **1.18.0 — AI診断は「鍵は自分の Mac から出さない」と約束したが、鍵そのものを外に置きたくない人には、まだクラウドの契約が要った。** **BYOK にローカルの Ollama を追加**(環境設定 > AI)——選べば鍵は不要で、リクエストも機外に出ない。あわせて検索バーに**「最近の検索」**(過去に検索した語をメニューから選び直せる)と、**Markdown/主要言語の構文ハイライト**(見出し・強調・コード・リンク、コメント・文字列・数値・予約語。対応: Python/Swift/JS/Shell/YAML/JSON/SQL/CSS/Java/PHP/Go/Ruby/Perl)を追加。一致行だけ表示(フィルタ)中に検索バーを開き直すと置換ボタンが押せる見た目のまま実は効かない不具合も修正——読み取り専用バナーに理由を出すようにした ✅(このリリース) +- **1.18.0 — AI診断は「鍵は自分の Mac から出さない」と約束したが、鍵そのものを外に置きたくない人には、まだクラウドの契約が要った。** **BYOK にローカルの Ollama を追加**(環境設定 > AI)——選べば鍵は不要で、リクエストも機外に出ない。あわせて検索バーに**「最近の検索」**(過去に検索した語をメニューから選び直せる)と、**Markdown/主要言語の構文ハイライト**(見出し・強調・コード・リンク、コメント・文字列・数値・予約語。対応: Python/Swift/JS/Shell/YAML/JSON/SQL/CSS/Java/PHP/Go/Ruby/Perl)を追加。一致行だけ表示(フィルタ)中に検索バーを開き直すと置換ボタンが押せる見た目のまま実は効かない不具合も修正——読み取り専用バナーに理由を出すようにした ✅(1.18.0) +- **1.19.0 — ターミナルやAIの回答からコピーしたパスは、File ▸ Openで手打ちし直す以外に開く方法が無かった。** **ファイル ▸ パスを指定して開く…(⌥⌘O)**は貼り付けたパスをそのまま受け取る——前後の引用符や`~`を処理し、ファイルはエディタで開く。フォルダはMrEditor内に行き先が無いのでFinderで表示するだけにした——独自の閲覧UIは作っていない ✅(このリリース) - **以降** — その他の分析ツール > **⚠️ v0.7 以前の dmg は、ダウンロードした Mac で起動しません。** @@ -388,8 +389,10 @@ vmmap $(pgrep -x MrEditor) | grep test_10gb.log # → 10.0G 2.8G 0K (vsiz | フォルダを横断して検索 | 結果をクリックするとその行が開く。Shift-JIS / EUC-JP も判定して読む | 10GB・リテラル 1 語で **3.14 秒**(ripgrep と互角) | 無料版にも「分析」メニューは同じ位置にあります(グレーアウトはしません)。押すと、 -その機能の説明が 1 枚出ます。MrkEditor は Polar で販売しています: -https://buy.polar.sh/polar_cl_S32h7CYTXCxbYQ5IimcpI4DayCFkHUYQ8I6bv2tOki8 +その機能の説明が 1 枚出ます。専用ページで詳しく読めます。購入は Polar からです: + +- https://mr-tabata.github.io/MrEditor/MrkEditor.ja.html +- https://buy.polar.sh/polar_cl_S32h7CYTXCxbYQ5IimcpI4DayCFkHUYQ8I6bv2tOki8 ## まだ「作らない」もの diff --git a/README.md b/README.md index e4e052f..106c3a0 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ python3 scripts/gen_testdata.py --encoding-set --out-dir testdata/ # UTF-8 / S python3 scripts/gen_testdata.py --size 10G --jp --out testdata/test_10gb.log ``` -Build a distributable disk image (`.build/MrEditor-1.18.0.dmg`): +Build a distributable disk image (`.build/MrEditor-1.19.0.dmg`): ```sh sh scripts/make_dmg.sh @@ -387,7 +387,8 @@ vmmap $(pgrep -x MrEditor) | grep test_10gb.log # → 10.0G 9.1G 0K (vsiz - **1.15.0 — Japanese CSVs were slow.** They arrive as Shift-JIS with CRLF and quoted fields, and searching one **decoded every line into a `String` before matching — 26.2s on a 1.06 GB, 5.8M-line file**. A release build did not help: the cost was 5.8M string allocations, not our own code. **Now the search term is encoded into Shift-JIS and the bytes are scanned directly. 2.4s — 11x.** A trailing byte can look like ASCII, so a match only counts when it lands on a character boundary. **The search bar also tells you when it is done**: the count and the percentage move together while scanning, and the percentage disappears at the end. Before, the count replaced "searching" as soon as the first match landed, so it looked settled while it was still growing ✅ (1.15.0) - **1.16.0 — Close the wrong tab a moment too soon, and the only way back was File ▸ Open, typing the path from memory.** **`⇧⌘T` reopens the file you just closed.** Closed documents are kept on a stack, most recent last; each press pops one and opens it, going further back the more you press. A document still untitled (never saved to a path) is not pushed — there is nothing on disk to reopen ✅ (1.16.0) - **1.17.0 — Copy something new, and the one before it is gone.** Paste in the wrong place and the only way back was switching to whatever app you copied it from and doing it again. **Edit ▸ Clipboard History** (and an optional toolbar button) lists recent copies — pick one and it pastes right there. **Nothing is written to disk**, and anything a password manager marks concealed (`org.nspasteboard.ConcealedType`) is never recorded ✅ (1.17.0) -- **1.18.0 — AI diagnose already promised the key never leaves your Mac, but anyone unwilling to hand a key to the cloud at all still had nowhere to point it.** **BYOK now includes local Ollama** (Preferences ▸ AI) — pick it and no key, and no request, ever leaves the machine. Also added: **recent searches** in the search bar (pick a past query from a menu), and **Markdown/major-language syntax highlighting** (headings, emphasis, code, links; comments, strings, numbers, keywords — Python/Swift/JS/Shell/YAML/JSON/SQL/CSS/Java/PHP/Go/Ruby/Perl). Fixed a bug where reopening the search bar while filtering left Replace looking enabled when it silently did nothing — the read-only banner now says why ✅ (this release) +- **1.18.0 — AI diagnose already promised the key never leaves your Mac, but anyone unwilling to hand a key to the cloud at all still had nowhere to point it.** **BYOK now includes local Ollama** (Preferences ▸ AI) — pick it and no key, and no request, ever leaves the machine. Also added: **recent searches** in the search bar (pick a past query from a menu), and **Markdown/major-language syntax highlighting** (headings, emphasis, code, links; comments, strings, numbers, keywords — Python/Swift/JS/Shell/YAML/JSON/SQL/CSS/Java/PHP/Go/Ruby/Perl). Fixed a bug where reopening the search bar while filtering left Replace looking enabled when it silently did nothing — the read-only banner now says why ✅ (1.18.0) +- **1.19.0 — A path copied from a terminal or an AI's answer had nowhere to go but File ▸ Open, retyped by hand.** **File ▸ Open by Path… (⌥⌘O)** takes a pasted path directly — surrounding quotes and `~` are handled — and opens a file in the editor. A folder has nowhere to go inside MrEditor, so it is revealed in Finder instead; no path-browsing UI was built for it ✅ (this release) - **later** — more analysis tooling > **⚠️ Builds up to v0.7 do not launch on a Mac that downloaded them.** @@ -413,8 +414,10 @@ answer out of it is paid.** | Search across folders | click a hit and the file opens at that line; Shift-JIS / EUC-JP detected per file | **3.14 s** for one literal over a single 10 GB log (on par with ripgrep) | The free app has the same Analyze menu in the same place (never greyed out); choosing an item -shows one page explaining that feature. MrkEditor is sold through Polar: -https://buy.polar.sh/polar_cl_S32h7CYTXCxbYQ5IimcpI4DayCFkHUYQ8I6bv2tOki8 +shows one page explaining that feature. See the dedicated MrkEditor page, or buy through Polar: + +- https://mr-tabata.github.io/MrEditor/MrkEditor.html +- https://buy.polar.sh/polar_cl_S32h7CYTXCxbYQ5IimcpI4DayCFkHUYQ8I6bv2tOki8 ## Not yet diff --git a/Sources/MrEditorCore/AppInfo.swift b/Sources/MrEditorCore/AppInfo.swift index 8683d05..eb8bcc1 100644 --- a/Sources/MrEditorCore/AppInfo.swift +++ b/Sources/MrEditorCore/AppInfo.swift @@ -20,7 +20,7 @@ enum AppInfo { static var version: String { (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? fallbackVersion } - private static let fallbackVersion = "1.18.0" + private static let fallbackVersion = "1.19.0" /// ヘルプメニューから開くプロジェクトページ。 static let helpURL = URL(string: "https://github.com/MR-TABATA/MrEditor")! diff --git a/scripts/make_app.sh b/scripts/make_app.sh index 99d8021..25c5ebc 100755 --- a/scripts/make_app.sh +++ b/scripts/make_app.sh @@ -17,7 +17,7 @@ BUNDLE_ID="${BUNDLE_ID:-com.aaedit.MrEditor}" # 実行ファイル名(SPM の executableTarget 名)。無料版=MrEditor / Pro 版=MrkEditor。 EXECUTABLE="${EXECUTABLE:-MrEditor}" # バージョン(Info.plist へ埋め込む)。make_dmg.sh と揃えるため VERSION で上書き可能。 -VERSION="${VERSION:-1.18.0}" +VERSION="${VERSION:-1.19.0}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" ICON="${ICON:-$ROOT/art/AppIcon.icns}" diff --git a/scripts/make_dmg.sh b/scripts/make_dmg.sh index 05a4fda..0d4e377 100755 --- a/scripts/make_dmg.sh +++ b/scripts/make_dmg.sh @@ -38,7 +38,7 @@ set -e ROOT="$(cd "$(dirname "$0")/.." && pwd)" APP_NAME="${APP_NAME:-MrEditor}" -VERSION="${VERSION:-1.18.0}" +VERSION="${VERSION:-1.19.0}" APP="$ROOT/.build/$APP_NAME.app" DMG="$ROOT/.build/$APP_NAME-$VERSION.dmg" diff --git a/site/index.en.html b/site/index.en.html index 1c9cea2..77a9179 100644 --- a/site/index.en.html +++ b/site/index.en.html @@ -112,6 +112,19 @@ .feat li{list-style:none;padding-left:26px;position:relative;color:var(--fg);font-size:15px} .feat li::before{content:"";position:absolute;left:0;top:9px;width:8px;height:8px;border-radius:2px;background:var(--teal)} .feat li span{color:var(--mut);font-size:13.5px} + .pro-box{position:relative;overflow:hidden;background:linear-gradient(180deg,rgba(20,184,166,.12),rgba(16,34,38,.72)); + border:1px solid rgba(20,184,166,.34);border-radius:18px;padding:30px;margin-top:12px; + box-shadow:0 28px 80px rgba(0,0,0,.28)} + .pro-box::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:linear-gradient(90deg,var(--teal),#5eead4,var(--amber))} + .pro-head{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:24px} + .pro-price{min-width:150px;border:1px solid rgba(20,184,166,.28);border-radius:14px;padding:14px 16px;background:rgba(5,16,18,.62); + text-align:right;color:var(--mut);font-size:13px} + .pro-price b{display:block;color:var(--fg);font-size:34px;line-height:1;font-weight:800;letter-spacing:-.5px} + .pro-actions{display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-top:22px} + .pro-actions .btn{font-size:16px;padding:15px 22px} + .pro-actions .buy{background:linear-gradient(180deg,#FACC15,var(--amber));color:#2b1700; + box-shadow:0 14px 34px rgba(245,158,11,.28)} + .pro-trial{color:var(--mut);font-size:13px;margin-top:12px;max-width:46em} footer{border-top:1px solid var(--line);padding:30px 0 50px;color:var(--mut);font-size:14px; display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px} @@ -124,6 +137,8 @@ .stats{grid-template-columns:repeat(2,1fr)} .grid3{grid-template-columns:1fr} .feat{grid-template-columns:1fr} + .pro-head{display:block} + .pro-price{text-align:left;margin-top:18px} nav .ghlink{display:none} } @@ -160,7 +175,7 @@
- macOS 13+ · v1.18.0 · MIT + macOS 13+ · v1.19.0 · MIT

The editor for text
you didn't write.

Logs. CSVs. Exports, dumps, diffs. Text that arrived from somewhere else — so you don't get to choose its size, its encoding, or how badly it is formatted. MrEditor opens it, filters it, lines up its columns, compares it, fixes it, and saves it without ever leaving the file half-written.

@@ -169,7 +184,7 @@

The editor for text
you didn't write.

記録を返さないので事後に割れない。中継ページは CF がボットを弾いて数えてくれる。 版はここから ?v= で渡す(中継側に版を書くと、リリースのたびに直す場所が増える)。 言語ごとに URL が違うので data-only で出し分ける。 --> - ↓ Download .dmg + ↓ Download .dmg View source
@@ -295,10 +310,15 @@

Read it. Then change it.

-
-
Paid companion: MrkEditor
-

Free reads one file. MrkEditor answers the question.

-

Everything above stays free, and stays MIT. Sold separately, MrkEditor is the other half: counting, summarising, seeing time, and searching across folders — the things you currently leave the editor to do. Numbers below are measured on the shipping code (M4 Max, August 2026), not estimates.

+
+
+
+
Paid companion: MrkEditor
+

Free reads one file. MrkEditor answers the question.

+

Everything above stays free, and stays MIT. Sold separately, MrkEditor is the other half: counting, summarising, seeing time, and searching across folders — the things you currently leave the editor to do. Numbers below are measured on the shipping code (M4 Max, August 2026), not estimates.

+
+
$39one-time purchase
+
  • Count by value — what sort | uniq -c | sort -rn does, on a column or a key=value field. 3.40 s over a 10 GB, 86,420,337-line log. Double-click a row and the document filters down to that value
  • Column stats — every column of a CSV in one pass: type, blanks, distinct, min/max, sum & average. 4.78 s for all 30 columns of a 1.18 GiB CSV (5,816,534 data rows). A column is only called numeric when every row reads as a number
  • @@ -306,7 +326,12 @@

    Free reads one file. MrkEditor answers the question.

  • Search across folders — 3.14 s for one literal across a single 10 GB log, on par with ripgrep. What ripgrep does not do: click a hit and the file opens at that line, and Shift-JIS / EUC-JP files are detected per file and read correctly

MrkEditor is a one-time purchase through Polar. The free app keeps the same Analyze menu in the same place; choosing an item explains what MrkEditor adds.

-

Buy MrkEditor

+ +

No separate trial: the free MrEditor app uses the same editor core, so try your real files there before buying. MrkEditor adds the Analyze menu.