From caa4207e39057ec764bdcf340cfa5de142c51418 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: Fri, 25 Sep 2026 14:28:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E6=A7=8B=E9=80=A0=E5=8C=96=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=AE=E5=88=97=E3=82=92=E3=83=89=E3=83=A9=E3=83=83?= =?UTF-8?q?=E3=82=B0=E3=81=A7=E4=B8=A6=E3=81=B9=E6=9B=BF=E3=81=88=E3=82=89?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88B19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StructuredHeaderViewの列名を掴んで並び替えるジェスチャーを追加。境界をつまむ既存の 幅変更(B7)とは排他な別ジェスチャーとして実装(境界ヒットが先勝ち)。対象はcsv/tsv/ ndjsonで、fixedWidthは対象外(項目=桁位置そのものなので並べ替えの意味が薄い)。 並び順は幅と同じくセッション限りで永続化しない。 TabularFormatterにmovingColumn/applyingLayoutを追加し、ビュープリセット(Pro・C9) が使うcore側の継ぎ目(ActiveDocument.columnWidths/columnOriginalIndices、 DocumentPane.applyStructuredLayout、MainWindowController.applyViewPreset、 ProFeature.viewPresets)も合わせて用意した。 実機確認で見つけて同日中に直したバグ2件: - 列を並べ替えると列名だけ動いて本文データが元の位置のままだった。cells(of:)がcsv/tsv のとき生の区切り分割を位置のまま返していたのが原因。Columnにoriginalindexを持たせ、 表示順へ引き直すようにした。 - 構造化表示(CSV1行目が太字)をオフにすると、戻した本文全体が太字になっていた。 NSTextStorage.replaceCharacters(in:with:)が置換範囲の先頭文字の属性を引き継ぐため。 復元直後にtextView.fontを明示的に揃え直すようにした。 Co-Authored-By: Claude Sonnet 5 --- .../MrEditorCore/Core/TabularFormatter.swift | 64 +++++++++++-- .../MrEditorCore/MainWindowController.swift | 18 ++++ Sources/MrEditorCore/Pro/ActiveDocument.swift | 11 +++ Sources/MrEditorCore/Pro/Pro.swift | 3 + .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../MrEditorCore/UI/StructuredBanner.swift | 24 ++++- .../UI/StructuredHeaderView.swift | 91 +++++++++++++++---- .../MrEditorCore/Viewer/DocumentPane.swift | 13 +++ .../MrEditorCore/Viewer/EditableViewer.swift | 34 +++++++ .../Viewer/PieceTableViewer.swift | 23 +++++ Tests/MrEditorTests/EditableViewerTests.swift | 18 ++++ .../MrEditorTests/TabularFormatterTests.swift | 84 +++++++++++++++++ 13 files changed, 361 insertions(+), 28 deletions(-) diff --git a/Sources/MrEditorCore/Core/TabularFormatter.swift b/Sources/MrEditorCore/Core/TabularFormatter.swift index 0efd15e..cfb228b 100644 --- a/Sources/MrEditorCore/Core/TabularFormatter.swift +++ b/Sources/MrEditorCore/Core/TabularFormatter.swift @@ -26,7 +26,12 @@ public enum StructuredMode: String, CaseIterable, Sendable { /// バイトオフセットに依存しないため、大ファイル(`PieceTableViewer`)と小ファイル /// (`EditableViewer`)の両経路から同じ呼び出しで使える。 public struct TabularFormatter { - struct Column { let key: String; let width: Int } // width は表示セル単位 + /// `originalIndex`=この列が元々どのセル位置(区切りで割った生の列・0始まり)を指すか。 + /// **並べ替え(B19)で `columns` の並び順が変わっても、値を取り違えないための固定点** + /// (csv/tsv は区切りで割った生セルが常にファイル順で来るため、`columns[j]` と + /// `j番目の生セル` が並べ替え後は一致しなくなる。`cells(of:)` がここを見て組み直す)。 + /// ndjson はキーで引くため、fixedWidth は並べ替え非対応のため、どちらも実質未使用。 + struct Column { let key: String; let width: Int; let originalIndex: Int } // width は表示セル単位 let mode: StructuredMode let columns: [Column] @@ -48,7 +53,40 @@ public struct TabularFormatter { guard columns.indices.contains(index) else { return self } var cols = columns let w = min(max(width, Self.minColumnWidth), Self.maxColumnWidth) - cols[index] = Column(key: cols[index].key, width: w) + cols[index] = Column(key: cols[index].key, width: w, originalIndex: cols[index].originalIndex) + return TabularFormatter(mode: mode, columns: cols, fields: fields) + } + + // MARK: - 列の並び替え(ヘッダ帯で列名をドラッグして変える) + + /// `index` 番目の列を `toIndex` の位置へ動かす。**構造体なので元は変わらない。** + /// 範囲外の index/toIndex は no-op(自分をそのまま返す)。 + func movingColumn(_ index: Int, to toIndex: Int) -> TabularFormatter { + guard columns.indices.contains(index), (0.. TabularFormatter { + var byKey: [String: Column] = [:] + for c in columns { byKey[c.key] = c } // 同名キーが複数あれば後勝ち(先頭優先の意図はない) + var cols: [Column] = [] + for key in order { + guard var c = byKey.removeValue(forKey: key) else { continue } + if let w = widths[key] { + c = Column(key: c.key, width: min(max(w, Self.minColumnWidth), Self.maxColumnWidth), + originalIndex: c.originalIndex) + } + cols.append(c) + } + // 対応しなかった列(想定外のキー構成)は元の並び順のまま末尾に残す。 + for c in columns where byKey[c.key] != nil { cols.append(c) } return TabularFormatter(mode: mode, columns: cols, fields: fields) } @@ -101,7 +139,8 @@ public struct TabularFormatter { } // 幅の上限は掛けない。項目の幅は定義で決まっていて、切り詰めると // 「桁を数えるために出した表示」で桁が落ちる。 - let cols = zip(fields, widths).map { Column(key: label(of: $0), width: max(1, $1)) } + let cols = zip(fields, widths).enumerated() + .map { i, pair in Column(key: label(of: pair.0), width: max(1, pair.1), originalIndex: i) } return TabularFormatter(mode: mode, columns: cols, fields: fields) case .csv, .tsv: let sep: Character = (mode == .csv) ? "," : "\t" @@ -113,7 +152,7 @@ public struct TabularFormatter { let name = j < header.count ? header[j] : "" var w = displayWidth(name) for cells in parsed where j < cells.count { w = max(w, displayWidth(cells[j])) } - cols.append(Column(key: name, width: clampWidth(w, cap: widthCap))) + cols.append(Column(key: name, width: clampWidth(w, cap: widthCap), originalIndex: j)) } return TabularFormatter(mode: mode, columns: cols, fields: []) case .ndjson: @@ -128,7 +167,9 @@ public struct TabularFormatter { maxW[key] = max(maxW[key] ?? 0, displayWidth(v)) } } - let cols = order.map { Column(key: $0, width: clampWidth(maxW[$0] ?? displayWidth($0), cap: widthCap)) } + let cols = order.enumerated().map { i, key in + Column(key: key, width: clampWidth(maxW[key] ?? displayWidth(key), cap: widthCap), originalIndex: i) + } return TabularFormatter(mode: mode, columns: cols, fields: []) case .json: // JSON はビューアが JsonFormatter に委譲するため、ここへは来ない(保険で空列)。 @@ -155,11 +196,18 @@ public struct TabularFormatter { columns.map { Self.pad($0.key, to: $0.width) }.joined(separator: Self.separator) } - /// 1 行 → セル配列。 + /// 1 行 → セル配列。**返す順序は `columns` の並び**(並べ替え後の表示順)。 + /// + /// csv/tsv は区切りで割ると常にファイル順(生の列位置)の配列になるので、 + /// `columns[j].originalIndex` で「表示上 j 番目の列は、生のどのセルか」を引き直す。 + /// これをせず生セルをそのまま返すと、並べ替え(B19)後に**列名は動くのに値が動かない** + /// (実機で踏んだ・2026-09-25)。 func cells(of rawLine: String) -> [String] { switch mode { - case .csv: return Self.splitDelimited(rawLine, sep: ",", csvQuotes: true) - case .tsv: return Self.splitDelimited(rawLine, sep: "\t", csvQuotes: false) + case .csv, .tsv: + let sep: Character = (mode == .csv) ? "," : "\t" + let raw = Self.splitDelimited(rawLine, sep: sep, csvQuotes: mode == .csv) + return columns.map { $0.originalIndex < raw.count ? raw[$0.originalIndex] : "" } case .ndjson: guard let obj = Self.jsonObject(rawLine) else { return [rawLine] } return columns.map { Self.valueString(obj[$0.key] ?? NSNull()) } diff --git a/Sources/MrEditorCore/MainWindowController.swift b/Sources/MrEditorCore/MainWindowController.swift index 208912f..9e8b9b0 100644 --- a/Sources/MrEditorCore/MainWindowController.swift +++ b/Sources/MrEditorCore/MainWindowController.swift @@ -242,6 +242,7 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { structuredBanner.translatesAutoresizingMaskIntoConstraints = false structuredBanner.isHidden = true structuredBanner.onRevert = { [weak self] in self?.setActiveStructuredMode(nil) } + structuredBanner.onPresetTapped = { [weak self] in self?.presentViewPresets() } content.addSubview(structuredBanner) // 右上に浮かべる(左のヘッダ列を隠さない)。検索バーと同じ角なので、既定では // 検索バーを下へ逃がす(下の searchBarTopConstraint)。どちらも掴んで動かせる。 @@ -1741,10 +1742,27 @@ public final class MainWindowController: NSWindowController, NSWindowDelegate { encoding: v.currentEncoding, structuredMode: v.structuredMode, columnNames: v.structuredColumnNames, + columnWidths: v.structuredColumnWidths, + columnOriginalIndices: v.structuredColumnOriginalIndices, text: v.restorableText, filterMatchLines: v.filterMatchLines) } + /// ビュープリセット(Pro・C9)の「適用」から呼ばれる。いま見えているペインへまとめて反映する。 + public func applyViewPreset(order: [String], widths: [String: Int]) { + activeViewer?.applyStructuredLayout(order: order, widths: widths) + } + + /// ビュープリセット(Pro)の入口。構造化ヘッダのボタンから呼ばれる(メニューではないので + /// `performProFeature` の 1 本には乗らず、ここに単独で持つ)。 + private func presentViewPresets() { + guard Pro.allows(.viewPresets) else { + ProInfoSheet.present(.viewPresets, in: window) + return + } + if !Pro.perform(.viewPresets, in: window) { NSSound.beep() } + } + /// 分析ペインを本文の下に差す(nil で外す)。**core は中身を知らない。** public func setAnalysisAccessory(_ view: NSView?, height: CGFloat = 220) { guard let content = window?.contentView else { return } diff --git a/Sources/MrEditorCore/Pro/ActiveDocument.swift b/Sources/MrEditorCore/Pro/ActiveDocument.swift index 357631e..ed9b7f2 100644 --- a/Sources/MrEditorCore/Pro/ActiveDocument.swift +++ b/Sources/MrEditorCore/Pro/ActiveDocument.swift @@ -17,6 +17,13 @@ public struct ActiveDocument { public let structuredMode: StructuredMode? /// 構造化表示中の列名(オフなら空)。並び順は画面の列順。 public let columnNames: [String] + /// 構造化表示中の列幅(key→幅)。ビュープリセット(C9)の「保存」が読む。 + public let columnWidths: [String: Int] + /// `columnNames` と同じ並びで、各列が元々どの生セル位置(0始まり)を指すか。 + /// **並べ替え(B19)で `columnNames` の並びが変わっても、列番号で値を取り出す分析 + /// (C1の「列」モード・C2列の統計)が取り違えないための情報。** csv/tsv だけ意味を持つ + /// (ndjson はキーで引く・fixedWidthは並べ替え非対応で常に恒等)。 + public let columnOriginalIndices: [Int] /// 小ファイル(編集ペイン)の本文。**大ファイルでは nil**=ファイルを自分で読むこと。 public let text: String? /// 「一致行だけ表示」中の一致行(**0 始まり**)。nil=フィルタしていない。 @@ -26,12 +33,16 @@ public struct ActiveDocument { encoding: DetectedEncoding, structuredMode: StructuredMode?, columnNames: [String], + columnWidths: [String: Int], + columnOriginalIndices: [Int], text: String?, filterMatchLines: [Int]?) { self.fileURL = fileURL self.encoding = encoding self.structuredMode = structuredMode self.columnNames = columnNames + self.columnWidths = columnWidths + self.columnOriginalIndices = columnOriginalIndices self.text = text self.filterMatchLines = filterMatchLines } diff --git a/Sources/MrEditorCore/Pro/Pro.swift b/Sources/MrEditorCore/Pro/Pro.swift index d003182..7f1e8fe 100644 --- a/Sources/MrEditorCore/Pro/Pro.swift +++ b/Sources/MrEditorCore/Pro/Pro.swift @@ -20,6 +20,9 @@ public enum ProFeature: String, CaseIterable, Sendable { case crossHostMerge /// 畳んだ俯瞰を AI に渡す(GB 級)。ロードマップ C4 = M5 の旗艦。 case aiOverview + /// 構造化表示の列の並び+幅を名前で保存・切替(ビュープリセット)。ロードマップ C9。 + /// 並べ替え操作そのもの(B19)は無料コア ── Pro に乗るのは保存・切替・複数管理だけ。 + case viewPresets // ⚠️ ここに **無い** ものは無料。特に: // - 時刻マージ(ローカル複数ファイル・⇧⌘O)… v1.11 で無料版として出荷済み。 diff --git a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings index d98165a..fc06e63 100644 --- a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings @@ -91,6 +91,7 @@ "structured.banner" = "Structured view (read-only)"; "structured.revert" = "Back to raw text"; "structured.hint" = "Display-only formatting. Your file is never modified (saving keeps the original CSV/JSON)."; +"structured.presetsButton" = "View Presets…"; "menu.jsonquery" = "JSON Query…"; "menu.columnRuler" = "Column Ruler"; "menu.columnRuler.fields" = "Column Fields…"; @@ -366,6 +367,8 @@ "pro.aiOverview.menu" = "AI Overview…"; "pro.aiOverview.name" = "AI overview"; "pro.aiOverview.blurb" = "Combines value counts, column stats and time distribution into one overview, and lets you ask an AI — what happened, where to look next — using only the measured numbers, never the raw log lines. Works even without an AI configured; the measured overview itself is right there in this tab. Measured: 6.81 s to build the overview for a 10GB, 67,156,671-line log (M4 Max, Sep 2026)."; +"pro.viewPresets.name" = "View presets"; +"pro.viewPresets.blurb" = "Save the column order and widths of a structured view under a name, and switch between them. Great for the same layout across files that share a column shape (per-store or per-day exports, for example). Reordering columns itself is already free — Pro adds saving and reusing it."; /* Intake (pipe, compressed) */ "open.failed.title" = "Could not open"; diff --git a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings index b24eb48..90e6483 100644 --- a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings @@ -91,6 +91,7 @@ "structured.banner" = "構造化表示(読み取り専用)"; "structured.revert" = "元のテキストに戻す"; "structured.hint" = "表示だけの整形です。ファイルは変更されません(保存しても元の CSV/JSON のまま)。"; +"structured.presetsButton" = "ビュープリセット…"; "menu.jsonquery" = "JSON クエリ…"; "menu.columnRuler" = "桁ルーラー"; "menu.columnRuler.fields" = "桁の項目定義…"; @@ -366,6 +367,8 @@ "pro.aiOverview.menu" = "AI 俯瞰…"; "pro.aiOverview.name" = "AI 俯瞰"; "pro.aiOverview.blurb" = "値の集計・列の統計・時間分布をまとめ、測った数字だけを AI に渡して「何が起きたか」「次にどこを見るべきか」を質問できます。ログの生の行そのものは渡しません。AI を設定していなくても、測った結果はこのタブだけで見られます。実測: 10GB・67,156,671 行のログを 6.81 秒で俯瞰にまとめました(M4 Max・2026-09)。"; +"pro.viewPresets.name" = "ビュープリセット"; +"pro.viewPresets.blurb" = "構造化表示の列の並びと幅を名前を付けて保存し、切り替えられます。同じ列構成の複数ファイル(店舗別・日別エクスポートなど)に、いつも同じ見た目をすぐ当てられます。並べ替え自体は無料版でもできます — Pro に乗るのは保存して使い回す部分だけです。"; /* 入口(パイプ・圧縮) */ "open.failed.title" = "開けませんでした"; diff --git a/Sources/MrEditorCore/UI/StructuredBanner.swift b/Sources/MrEditorCore/UI/StructuredBanner.swift index afca0ac..898f775 100644 --- a/Sources/MrEditorCore/UI/StructuredBanner.swift +++ b/Sources/MrEditorCore/UI/StructuredBanner.swift @@ -7,10 +7,13 @@ import AppKit final class StructuredBanner: NSView { /// 「元に戻す」を押したとき。 var onRevert: (() -> Void)? + /// 「ビュープリセット…」を押したとき(B19/C9)。無料ビルドでも押せる(説明シートで止まる)。 + var onPresetTapped: (() -> Void)? static let height: CGFloat = 26 private let label = NSTextField(labelWithString: "") + private let presetButton = NSButton(title: "", target: nil, action: nil) override init(frame frameRect: NSRect) { super.init(frame: frameRect); setup() } required init?(coder: NSCoder) { super.init(coder: coder); setup() } @@ -39,7 +42,16 @@ final class StructuredBanner: NSView { revert.translatesAutoresizingMaskIntoConstraints = false revert.setContentHuggingPriority(.required, for: .horizontal) - addSubview(icon); addSubview(label); addSubview(revert) + presetButton.title = L("structured.presetsButton") + presetButton.bezelStyle = .rounded + presetButton.controlSize = .small + presetButton.font = .systemFont(ofSize: 11) + presetButton.translatesAutoresizingMaskIntoConstraints = false + presetButton.setContentHuggingPriority(.required, for: .horizontal) + presetButton.target = self + presetButton.action = #selector(presetTapped) + + addSubview(icon); addSubview(label); addSubview(presetButton); addSubview(revert) NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), icon.centerYAnchor.constraint(equalTo: centerYAnchor), @@ -49,15 +61,20 @@ final class StructuredBanner: NSView { label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), label.centerYAnchor.constraint(equalTo: centerYAnchor), - revert.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 12), + presetButton.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 12), + presetButton.centerYAnchor.constraint(equalTo: centerYAnchor), + + revert.leadingAnchor.constraint(equalTo: presetButton.trailingAnchor, constant: 8), revert.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), revert.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } - /// モード名(CSV/TSV/NDJSON)を反映する。 + /// モード名(CSV/TSV/NDJSON)を反映する。**プリセットボタンは fixedWidth では隠す** + /// (並べ替え・B19が対象外のモードなので、保存して使い回す先も無い)。 func configure(mode: StructuredMode) { label.stringValue = L("structured.banner") + " · " + mode.displayName + presetButton.isHidden = (mode == .fixedWidth) applyBackground() // テーマが変わっていることがある } @@ -76,4 +93,5 @@ final class StructuredBanner: NSView { } @objc private func revertTapped() { onRevert?() } + @objc private func presetTapped() { onPresetTapped?() } } diff --git a/Sources/MrEditorCore/UI/StructuredHeaderView.swift b/Sources/MrEditorCore/UI/StructuredHeaderView.swift index d5dd08d..24c9bbe 100644 --- a/Sources/MrEditorCore/UI/StructuredHeaderView.swift +++ b/Sources/MrEditorCore/UI/StructuredHeaderView.swift @@ -25,6 +25,14 @@ final class StructuredHeaderView: NSView { /// 数百万行を組み直さない)。 var onResize: ((Int, Int) -> Void)? + /// 列を並べ替え終わったとき(元の列番号・移動先の列番号)。**離した時だけ呼ぶ** + /// (resize と同じ作法。引きずるたびに本文を組み直さない)。 + var onReorder: ((Int, Int) -> Void)? + + /// この整形が並べ替えに対応しているか(B19: fixedWidth は対象外 ── 項目=桁位置そのもの + /// なので、呼ぶ側が `mode` を見て設定する)。既定は対応あり。 + var allowsReorder: Bool = true + /// 掴み手の当たり判定(桁)。細い線をピクセル単位で狙わせない。 private let hitTolerance = 1 @@ -32,6 +40,10 @@ final class StructuredHeaderView: NSView { private var draggingIndex: Int? private var draggingColumn: Int? + /// 並べ替え中の掴み元の列と、いまの挿入先の列(どちらも `columns` の index)。 + private var reorderSourceIndex: Int? + private var reorderTargetIndex: Int? + override var isFlipped: Bool { true } override var isOpaque: Bool { EditorTheme.isOpaqueBackground } @@ -52,6 +64,12 @@ final class StructuredHeaderView: NSView { /// 掴み手のある桁(各列の右端の次)。 private var handleColumns: [Int] { columns.map { $0.start + $0.width } } + /// x にある列の index(並べ替えの掴み元・挿入先の判定に使う)。画面外なら nil。 + private func columnIndex(atViewX x: CGFloat) -> Int? { + let col = column(atViewX: x) + return columns.firstIndex { col >= $0.start && col < $0.start + max($0.width, 1) } + } + // MARK: - 描画 override func draw(_ dirtyRect: NSRect) { @@ -78,22 +96,42 @@ final class StructuredHeaderView: NSView { drawNames(theme: theme) drawHandles(theme: theme) + drawReorderFeedback(theme: theme) } /// 列名。**列の幅で切る**(隣へはみ出すと、どの名前がどの列か分からなくなる)。 + /// 並べ替え中は掴み元の列を薄く見せて「持ち上げている」ことを示す。 private func drawNames(theme: EditorColorTheme) { let font = NSFont.systemFont(ofSize: 10, weight: .semibold) - let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.chromeSecondaryText] - for col in columns { + for (i, col) in columns.enumerated() { let x = viewX(ofColumn: col.start) let w = CGFloat(col.width) * columnWidth guard x + w > 0, x < bounds.width else { continue } // 画面外は描かない + let alpha: CGFloat = (i == reorderSourceIndex) ? 0.35 : 1.0 + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, .foregroundColor: theme.chromeSecondaryText.withAlphaComponent(alpha), + ] let name = TabularFormatter.pad(col.name, to: col.width) NSAttributedString(string: name, attributes: attrs) .draw(in: NSRect(x: x, y: 3, width: w, height: bounds.height - 5)) } } + /// 並べ替え中、挿入先を示す縦線。**resize のプレビュー線とは別スタイル** + /// (太い実線+アクセント色)にして、「境界を動かす」のか「列ごと差し込む」のか区別する。 + private func drawReorderFeedback(theme: EditorColorTheme) { + guard reorderSourceIndex != nil, let target = reorderTargetIndex, + columns.indices.contains(target) else { return } + let col = columns[target] + NSColor.controlAccentColor.setStroke() + let path = NSBezierPath() + path.lineWidth = 3 + let x = floor(viewX(ofColumn: col.start)) + 0.5 + path.move(to: NSPoint(x: x, y: 0)) + path.line(to: NSPoint(x: x, y: bounds.height)) + path.stroke() + } + /// 列の境界(掴み手)。引きずっている間はその位置に濃い線を出す。 private func drawHandles(theme: EditorColorTheme) { theme.separator.setStroke() @@ -122,33 +160,52 @@ final class StructuredHeaderView: NSView { let p = convert(event.locationInWindow, from: nil) guard p.x >= contentInset else { return } let col = column(atViewX: p.x) - // 一番近い掴み手(許容 1 桁)。 + // 一番近い掴み手(許容 1 桁)。境界を掴んだら resize、外れたら並べ替えを試す + // (2 つのジェスチャーは排他 ── 境界が先勝ち)。 let hit = handleColumns.enumerated() .filter { abs($0.element - col) <= hitTolerance } .min { abs($0.element - col) < abs($1.element - col) } - guard let hit else { return } - draggingIndex = hit.offset - draggingColumn = hit.element + if let hit { + draggingIndex = hit.offset + draggingColumn = hit.element + needsDisplay = true + return + } + guard allowsReorder, let index = columnIndex(atViewX: p.x) else { return } + reorderSourceIndex = index + reorderTargetIndex = index needsDisplay = true } override func mouseDragged(with event: NSEvent) { - guard draggingIndex != nil else { return } let p = convert(event.locationInWindow, from: nil) - let col = column(atViewX: max(contentInset, p.x)) - guard col != draggingColumn else { return } - draggingColumn = col // 線を動かすだけ。本文はまだ組み直さない。 + if draggingIndex != nil { + let col = column(atViewX: max(contentInset, p.x)) + guard col != draggingColumn else { return } + draggingColumn = col // 線を動かすだけ。本文はまだ組み直さない。 + needsDisplay = true + return + } + guard reorderSourceIndex != nil else { return } + guard let target = columnIndex(atViewX: max(contentInset, p.x)), target != reorderTargetIndex else { return } + reorderTargetIndex = target // 挿入先の線を動かすだけ。本文はまだ組み直さない。 needsDisplay = true } override func mouseUp(with event: NSEvent) { - defer { draggingIndex = nil; draggingColumn = nil; needsDisplay = true } - guard let index = draggingIndex, let to = draggingColumn, - columns.indices.contains(index) else { return } - let width = max(TabularFormatter.minColumnWidth, - min(to - columns[index].start, TabularFormatter.maxColumnWidth)) - guard width != columns[index].width else { return } - onResize?(index, width) + if draggingIndex != nil { + defer { draggingIndex = nil; draggingColumn = nil; needsDisplay = true } + guard let index = draggingIndex, let to = draggingColumn, + columns.indices.contains(index) else { return } + let width = max(TabularFormatter.minColumnWidth, + min(to - columns[index].start, TabularFormatter.maxColumnWidth)) + guard width != columns[index].width else { return } + onResize?(index, width) + return + } + defer { reorderSourceIndex = nil; reorderTargetIndex = nil; needsDisplay = true } + guard let from = reorderSourceIndex, let to = reorderTargetIndex, from != to else { return } + onReorder?(from, to) } override func resetCursorRects() { diff --git a/Sources/MrEditorCore/Viewer/DocumentPane.swift b/Sources/MrEditorCore/Viewer/DocumentPane.swift index a88ec9e..3cfbc32 100644 --- a/Sources/MrEditorCore/Viewer/DocumentPane.swift +++ b/Sources/MrEditorCore/Viewer/DocumentPane.swift @@ -81,11 +81,21 @@ protocol DocumentPane: NSView { var structuredMode: StructuredMode? { get } /// 構造化表示中の列名(オフなら空)。分析(Pro)が「どの列を数えるか」を出すために読む。 var structuredColumnNames: [String] { get } + /// 構造化表示中の列幅(key→幅)。ビュープリセット(Pro・C9)の「保存」が読む。 + var structuredColumnWidths: [String: Int] { get } + /// 構造化表示中の列が、元々どの生セル位置(区切りで割った位置・0始まり)を指すか + /// (`structuredColumnNames` と同じ並び)。**並べ替え(B19)で表示順が変わっても、 + /// Pro の列番号指定の分析(C1の「列」モード・C2列の統計)が値を取り違えないための情報。** + /// csv/tsv だけ意味を持つ(ndjson はキーで引く・fixedWidthは並べ替え非対応で常に恒等)。 + var structuredColumnOriginalIndices: [Int] { get } /// 「一致行だけ表示」中の一致行(**0 始まり**)。フィルタしていなければ nil。 /// 分析(Pro)は nil でなければ**その行だけ**を対象にする。 var filterMatchLines: [Int]? { get } /// 構造化表示モードを設定する(nil でオフ=通常表示へ復帰)。 func setStructuredMode(_ mode: StructuredMode?) + /// 列の並び順と幅をまとめて適用する(ビュープリセット・Pro・C9 の「適用」から呼ばれる)。 + /// 列名(key)で対応付ける。構造化表示中でなければ何もしない。 + func applyStructuredLayout(order: [String], widths: [String: Int]) /// JSON その場クエリ(jmespath 相当・結果は揮発)に対応するか。小ファイルペインのみ。 var supportsJsonQuery: Bool { get } @@ -215,8 +225,11 @@ extension DocumentPane { var supportsJsonReformat: Bool { false } var structuredMode: StructuredMode? { nil } var structuredColumnNames: [String] { [] } + var structuredColumnWidths: [String: Int] { [:] } + var structuredColumnOriginalIndices: [Int] { [] } var filterMatchLines: [Int]? { nil } func setStructuredMode(_ mode: StructuredMode?) {} + func applyStructuredLayout(order: [String], widths: [String: Int]) {} var supportsJsonQuery: Bool { false } var jsonQueryIsActive: Bool { false } diff --git a/Sources/MrEditorCore/Viewer/EditableViewer.swift b/Sources/MrEditorCore/Viewer/EditableViewer.swift index b432bdb..1009441 100644 --- a/Sources/MrEditorCore/Viewer/EditableViewer.swift +++ b/Sources/MrEditorCore/Viewer/EditableViewer.swift @@ -113,6 +113,11 @@ final class EditableViewer: NSView, DocumentPane, NSTextViewDelegate { var supportsJsonReformat: Bool { true } // 全文を保持する小ファイルペインなので単一 JSON 整形が可能 var structuredMode: StructuredMode? { jsonPrettyActive ? .json : structuredFormatter?.mode } var structuredColumnNames: [String] { structuredFormatter?.columns.map(\.key) ?? [] } + var structuredColumnWidths: [String: Int] { + guard let fmt = structuredFormatter else { return [:] } + return Dictionary(uniqueKeysWithValues: fmt.columns.map { ($0.key, $0.width) }) + } + var structuredColumnOriginalIndices: [Int] { structuredFormatter?.columns.map(\.originalIndex) ?? [] } /// フィルタ中の一致行(0 始まり)。**表示行ではなく一致行**を返す /// (前後 N 行の文脈まで「一致」として数えると、分析の件数が水増しされる)。 var filterMatchLines: [Int]? { preFilterText != nil ? filterMatchedLineNumbers : nil } @@ -213,6 +218,7 @@ final class EditableViewer: NSView, DocumentPane, NSTextViewDelegate { structuredHeader.translatesAutoresizingMaskIntoConstraints = false structuredHeader.isHidden = true structuredHeader.onResize = { [weak self] i, w in self?.resizeStructuredColumn(i, to: w) } + structuredHeader.onReorder = { [weak self] from, to in self?.reorderStructuredColumn(from, to) } addSubview(structuredHeader) addSubview(columnRuler) @@ -353,6 +359,7 @@ final class EditableViewer: NSView, DocumentPane, NSTextViewDelegate { structuredHeader.columns = zip(fmt.columns, starts).map { StructuredHeaderView.Column(name: $0.key, start: $1, width: $0.width) } + structuredHeader.allowsReorder = (fmt.mode != .fixedWidth) } /// Tab で**次の項目の桁まで空白を詰める**(=後ろの文字列がその桁へずれる)。詰めたら true。 @@ -1284,6 +1291,12 @@ final class EditableViewer: NSView, DocumentPane, NSTextViewDelegate { setWrapMode(wrapped: true) textView.delegate = nil textView.string = original + // `NSTextStorage.replaceCharacters(in:with:)` は置き換える範囲の**先頭文字の属性**を + // 新しい文字列へ引き継ぐ。CSV/TSV の構造化表示は 1 行目を太字にしているので、 + // 直前まで太字だった 1 文字目から続けて置き換えると、戻した本文が丸ごと太字になる + // (実機で踏んだ・2026-09-25)。`applyCurrentFontSize()` と同じやり方で全体を通常の + // フォントへ揃え直す。 + textView.font = EditorFont.current() textView.delegate = self applyParagraphStyle(); applyColors() textView.setSelectedRange(NSRange(location: 0, length: 0)) @@ -1360,6 +1373,20 @@ final class EditableViewer: NSView, DocumentPane, NSTextViewDelegate { renderStructured() } + /// 列をドラッグで並べ替えた(ヘッダ帯から呼ばれる。B19)。 + private func reorderStructuredColumn(_ from: Int, _ to: Int) { + guard let fmt = structuredFormatter else { return } + structuredFormatter = fmt.movingColumn(from, to: to) + renderStructured() + } + + /// ビュープリセット(Pro・C9)の「適用」から呼ばれる。構造化表示中でなければ無視。 + func applyStructuredLayout(order: [String], widths: [String: Int]) { + guard let fmt = structuredFormatter else { return } + structuredFormatter = fmt.applyingLayout(order: order, widths: widths) + renderStructured() + } + /// 整形済みの読み取り専用テキスト(等幅・CSV/TSV は先頭行を太字)。 private func formattedText(lines: [String], formatter: TabularFormatter) -> NSAttributedString { let font = EditorFont.current() @@ -1720,6 +1747,13 @@ extension EditableViewer { effectiveRange: nil) as? NSFont else { return [] } return font.fontDescriptor.symbolicTraits } + /// **本物の**(temporary ではない)textStorage 上のフォント。構造化表示の太字(1行目)が + /// 元のテキストへ戻した後まで残っていないかを確かめるのに使う。 + func _testStoredFontTraits(at location: Int) -> NSFontDescriptor.SymbolicTraits { + guard let font = textView.textStorage?.attribute(.font, at: location, effectiveRange: nil) as? NSFont + else { return [] } + return font.fontDescriptor.symbolicTraits + } func _testHasStrikethrough(at location: Int) -> Bool { textView.layoutManager?.temporaryAttribute(.strikethroughStyle, atCharacterIndex: location, effectiveRange: nil) != nil diff --git a/Sources/MrEditorCore/Viewer/PieceTableViewer.swift b/Sources/MrEditorCore/Viewer/PieceTableViewer.swift index 74881d8..eecefc6 100644 --- a/Sources/MrEditorCore/Viewer/PieceTableViewer.swift +++ b/Sources/MrEditorCore/Viewer/PieceTableViewer.swift @@ -114,6 +114,11 @@ final class PieceTableViewer: NSView, DocumentPane { var supportsStructured: Bool { fileBuffer != nil } var structuredMode: StructuredMode? { structuredFormatter?.mode } var structuredColumnNames: [String] { structuredFormatter?.columns.map(\.key) ?? [] } + var structuredColumnWidths: [String: Int] { + guard let fmt = structuredFormatter else { return [:] } + return Dictionary(uniqueKeysWithValues: fmt.columns.map { ($0.key, $0.width) }) + } + var structuredColumnOriginalIndices: [Int] { structuredFormatter?.columns.map(\.originalIndex) ?? [] } /// フィルタ中の一致行(0 始まり)。上限で打ち切られている場合もそのまま返す /// (分析側は「N 件以上」を自分で言う)。 var filterMatchLines: [Int]? { filterMode ? searchResults.lines : nil } @@ -192,6 +197,7 @@ final class PieceTableViewer: NSView, DocumentPane { columnRuler.onMoveGuide = { [weak self] from, to in self?.moveColumnGuide(from, to: to) ?? false } structuredHeader.isHidden = true structuredHeader.onResize = { [weak self] i, w in self?.resizeStructuredColumn(i, to: w) } + structuredHeader.onReorder = { [weak self] from, to in self?.reorderStructuredColumn(from, to) } addSubview(structuredHeader) addSubview(columnRuler) @@ -365,6 +371,7 @@ final class PieceTableViewer: NSView, DocumentPane { structuredHeader.columns = zip(fmt.columns, starts).map { StructuredHeaderView.Column(name: $0.key, start: $1, width: $0.width) } + structuredHeader.allowsReorder = (fmt.mode != .fixedWidth) } /// 列幅をドラッグで変えた(ヘッダ帯から呼ばれる)。**行を組み直すのは描画のたびなので、 @@ -376,6 +383,22 @@ final class PieceTableViewer: NSView, DocumentPane { refresh() } + /// 列をドラッグで並べ替えた(ヘッダ帯から呼ばれる。B19)。 + private func reorderStructuredColumn(_ from: Int, _ to: Int) { + guard let fmt = structuredFormatter else { return } + structuredFormatter = fmt.movingColumn(from, to: to) + syncStructuredHeader() + refresh() + } + + /// ビュープリセット(Pro・C9)の「適用」から呼ばれる。構造化表示中でなければ無視。 + func applyStructuredLayout(order: [String], widths: [String: Int]) { + guard let fmt = structuredFormatter else { return } + structuredFormatter = fmt.applyingLayout(order: order, widths: widths) + syncStructuredHeader() + refresh() + } + // MARK: - 桁ルーラー(A)と桁ガイド(B) var supportsColumnRuler: Bool { true } diff --git a/Tests/MrEditorTests/EditableViewerTests.swift b/Tests/MrEditorTests/EditableViewerTests.swift index 8b31242..9a01b6e 100644 --- a/Tests/MrEditorTests/EditableViewerTests.swift +++ b/Tests/MrEditorTests/EditableViewerTests.swift @@ -111,6 +111,24 @@ final class EditableViewerTests: XCTestCase { XCTAssertEqual(v._testText, text) // 元の本文に復元 } + /// 実機で踏んだバグ(2026-09-25): CSV/TSV の構造化表示は1行目を太字にするが、 + /// オフに戻すと `NSTextStorage.replaceCharacters` が置換範囲の先頭文字(=太字だった + /// 1行目の1文字目)の属性を引き継いでしまい、**戻した本文が丸ごと太字になる**。 + func testExitStructuredDoesNotLeaveBoldFont() throws { + let text = "name,age\nAlice,30\nBob,7\n" + let url = tempURL() + defer { try? FileManager.default.removeItem(at: url) } + try text.data(using: .utf8)!.write(to: url) + + let v = EditableViewer() + XCTAssertTrue(v.open(url: url)) + v.setStructuredMode(.csv) + XCTAssertTrue(v._testStoredFontTraits(at: 0).contains(.bold)) // 整形中の1行目は太字(前提) + v.setStructuredMode(nil) + XCTAssertFalse(v._testStoredFontTraits(at: 0).contains(.bold)) // 戻した本文は太字でない + XCTAssertFalse(v._testStoredFontTraits(at: v._testText.utf16.count - 1).contains(.bold)) + } + /// JSON 整形は表示だけの読み取り専用変換。オフで元の本文に戻り、保存は元の JSON を書く。 func testJsonPrettyIsDisplayOnlyAndReversible() throws { let text = #"{"b":2,"a":1}"# diff --git a/Tests/MrEditorTests/TabularFormatterTests.swift b/Tests/MrEditorTests/TabularFormatterTests.swift index a0fe0ad..e6bc2f4 100644 --- a/Tests/MrEditorTests/TabularFormatterTests.swift +++ b/Tests/MrEditorTests/TabularFormatterTests.swift @@ -101,4 +101,88 @@ final class TabularFormatterTests: XCTestCase { XCTAssertEqual(cells[0], "42") XCTAssertEqual(cells[1], "") } + + // MARK: - movingColumn(B19: 列の並べ替え) + + func testMovingColumnFrontToBack() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b,c", "1,2,3"]) + let moved = f.movingColumn(0, to: 2) + XCTAssertEqual(moved.columns.map(\.key), ["b", "c", "a"]) + } + + func testMovingColumnBackToFront() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b,c", "1,2,3"]) + let moved = f.movingColumn(2, to: 0) + XCTAssertEqual(moved.columns.map(\.key), ["c", "a", "b"]) + } + + func testMovingColumnAdjacentSwap() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b,c", "1,2,3"]) + let moved = f.movingColumn(1, to: 2) + XCTAssertEqual(moved.columns.map(\.key), ["a", "c", "b"]) + } + + func testMovingColumnOutOfRangeIndexIsNoOp() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b,c", "1,2,3"]) + XCTAssertEqual(f.movingColumn(-1, to: 1).columns.map(\.key), ["a", "b", "c"]) + XCTAssertEqual(f.movingColumn(5, to: 1).columns.map(\.key), ["a", "b", "c"]) + XCTAssertEqual(f.movingColumn(0, to: -1).columns.map(\.key), ["a", "b", "c"]) + XCTAssertEqual(f.movingColumn(0, to: 5).columns.map(\.key), ["a", "b", "c"]) + } + + func testMovingColumnPreservesWidths() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["name,age", "Alice,30"]) + let moved = f.movingColumn(0, to: 1) + XCTAssertEqual(moved.columns.map(\.key), ["age", "name"]) + XCTAssertEqual(moved.columns[1].width, 5) // "name" 列(元 index 0)の幅が付いてくる + } + + /// 実機で踏んだバグ(2026-09-25): 並べ替えると**列名は動くのに値が動かない**。 + /// `cells(of:)`/`format` は表示順に値を並べ直さねばならない。 + func testMovingColumnAlsoMovesRenderedValues() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["name,age,city,role", "Alice,30,Tokyo,Engineer"]) + // city(index 2) を age(index 1) の前へ動かす → 表示順は name, city, age, role。 + let moved = f.movingColumn(2, to: 1) + XCTAssertEqual(moved.columns.map(\.key), ["name", "city", "age", "role"]) + let cells = moved.cells(of: "Alice,30,Tokyo,Engineer") + XCTAssertEqual(cells, ["Alice", "Tokyo", "30", "Engineer"]) // 列名と同じ順で値も動く + XCTAssertTrue(moved.format("Alice,30,Tokyo,Engineer").hasPrefix("Alice")) + XCTAssertTrue(moved.format("Alice,30,Tokyo,Engineer").contains("Tokyo")) + } + + func testApplyingLayoutAlsoMovesRenderedValues() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["name,age,city,role", "Alice,30,Tokyo,Engineer"]) + let applied = f.applyingLayout(order: ["city", "name", "role", "age"], widths: [:]) + XCTAssertEqual(applied.cells(of: "Alice,30,Tokyo,Engineer"), ["Tokyo", "Alice", "Engineer", "30"]) + } + + func testMovingColumnTSVAlsoMovesRenderedValues() { + let f = TabularFormatter.build(mode: .tsv, sampleLines: ["a\tb\tc", "1\t2\t3"]) + let moved = f.movingColumn(0, to: 2) + XCTAssertEqual(moved.cells(of: "1\t2\t3"), ["2", "3", "1"]) + } + + // MARK: - applyingLayout(Pro のビュープリセットが使う) + + func testApplyingLayoutReordersAndResizes() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b,c", "1,2,3"]) + let applied = f.applyingLayout(order: ["c", "a", "b"], widths: ["c": 9, "a": 8]) + XCTAssertEqual(applied.columns.map(\.key), ["c", "a", "b"]) + XCTAssertEqual(applied.columns[0].width, 9) + XCTAssertEqual(applied.columns[1].width, 8) + XCTAssertEqual(applied.columns[2].width, f.columns[1].width) // "b" は widths に無い→幅そのまま + } + + func testApplyingLayoutUnknownOrderKeyIsIgnored() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b", "1,2"]) + let applied = f.applyingLayout(order: ["a", "ghost", "b"], widths: [:]) + XCTAssertEqual(applied.columns.map(\.key), ["a", "b"]) + } + + func testApplyingLayoutMissingKeyStaysAtEndInOriginalOrder() { + let f = TabularFormatter.build(mode: .csv, sampleLines: ["a,b,c", "1,2,3"]) + // プリセットは "a" しか知らない(保存後にファイル形状が増えた想定)→ b, c は元順で末尾に残る。 + let applied = f.applyingLayout(order: ["a"], widths: [:]) + XCTAssertEqual(applied.columns.map(\.key), ["a", "b", "c"]) + } } From b8c33609ae520b9d90737d972d8fe6a1b60d73cc 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: Fri, 25 Sep 2026 14:44:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?1.18.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 v1.18.0(ローカルAI)は既にタグ・GitHub Releaseとも公開済みだったが、版文字列と リリース文書の更新が漏れており、check_consistency.pyのpro_gate以外の版チェックで CIが落ちていた(B19/C9のPRとは無関係の既存の抜け)。 版: AppInfo.fallbackVersion / make_app.sh / make_dmg.sh / LPバッジ・DLリンクを 1.17.0→1.18.0に統一。 文書: README(日英)・リリース全史(web/releases.src.html)に1.18.0の行/eraを追加。 内容は本人が既にGitHub Releaseへ書いていたリリースノート(Ollamaのローカル追加・ 検索欄の最近の検索・Markdown/主要言語の構文ハイライト・フィルタ中の置換ボタンの バグ修正)を元にした。前版1.17.0の「✅(このリリース)」は「✅(1.17.0)」へ降格。 build_site.pyでsite/を再生成。 一貫性チェッカ ✅ 全項目通過。 Co-Authored-By: Claude Sonnet 5 --- README.ja.md | 7 ++++--- README.md | 7 ++++--- Sources/MrEditorCore/AppInfo.swift | 2 +- scripts/make_app.sh | 2 +- scripts/make_dmg.sh | 2 +- site/index.en.html | 4 ++-- site/index.html | 4 ++-- site/index.ja.html | 4 ++-- site/releases.html | 18 +++++++++++++++--- site/releases.ja.html | 18 +++++++++++++++--- web/lp.src.html | 6 +++--- web/releases.src.html | 28 +++++++++++++++++++++------- 12 files changed, 71 insertions(+), 31 deletions(-) diff --git a/README.ja.md b/README.ja.md index dddc350..b0f26ba 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.17.0.dmg`)の作成: +配布用ディスクイメージ(`.build/MrEditor-1.18.0.dmg`)の作成: ```sh sh scripts/make_dmg.sh @@ -361,8 +361,9 @@ vmmap $(pgrep -x MrEditor) | grep test_10gb.log # → 10.0G 2.8G 0K (vsiz - **1.14.0 — 見たいログはサーバーにある。** `ssh host:/var/log/app.log` を **ファイル ▸ リモートを開く…(⌃⌘O)** に入れると、落とさずに開く。見ているところだけを取ってくるので 10GB でも待たない。開くとまず末尾 —— 障害は末尾にある —— で、行番号は本物(`wc -l` は向こうで走り、返ってくるのは数字 1 つ)。**絞り込みは向こうで走り**(`grep -n`)、**本文を 1 バイトも転送せずに**一致行と行番号が返る。送らないぶん、手元で走査するより速い。末尾追従は向こうの `tail -f` を流し込み、止めれば向こうも止まるので、**あなたのサーバーにプロセスは残らない** ✅(1.14.0) - **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 — 新しくコピーすると、直前のコピーは消える。** 貼り付け先を間違えたとき、戻る方法がコピー元のアプリを開き直す以外に無かった。**編集メニュー→クリップボード履歴**(ツールバーにも任意で追加できる)に直近のコピーを並べ、選べばそのまま貼り付く。**ディスクには残さず**、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)を追加。一致行だけ表示(フィルタ)中に検索バーを開き直すと置換ボタンが押せる見た目のまま実は効かない不具合も修正——読み取り専用バナーに理由を出すようにした ✅(このリリース) +- **以降** — その他の分析ツール > **⚠️ v0.7 以前の dmg は、ダウンロードした Mac で起動しません。** > `.app` バンドルに署名を施していなかったため、署名の封が矛盾し、macOS が diff --git a/README.md b/README.md index 39c43c1..2ebc800 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.17.0.dmg`): +Build a distributable disk image (`.build/MrEditor-1.18.0.dmg`): ```sh sh scripts/make_dmg.sh @@ -386,8 +386,9 @@ vmmap $(pgrep -x MrEditor) | grep test_10gb.log # → 10.0G 9.1G 0K (vsiz - **1.14.0 — The log you want is on a server.** `ssh host:/var/log/app.log` in **File ▸ Open Remote… (⌃⌘O)** opens it without downloading it: only the part you are looking at comes over, so a 10 GB file does not make you wait. It opens at the tail, where the incident is, and the line numbers are real (`wc -l` runs over there and one number comes back). **Filtering runs on the far side** (`grep -n`), so a match and its line number return without a single byte of the body crossing the wire — faster than scanning locally, because nothing is sent. Following the tail streams `tail -f` from over there and stops it when you stop, so no process is left behind on your server** ✅ (1.14.0) - **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 ✅ (this release) -- **later** — syntax/log highlighting, and more analysis tooling +- **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) +- **later** — more analysis tooling > **⚠️ Builds up to v0.7 do not launch on a Mac that downloaded them.** > The `.app` bundle was never code-signed, so its signature seal was inconsistent and diff --git a/Sources/MrEditorCore/AppInfo.swift b/Sources/MrEditorCore/AppInfo.swift index 0f90d07..8683d05 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.17.0" + private static let fallbackVersion = "1.18.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 401360e..99d8021 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.17.0}" +VERSION="${VERSION:-1.18.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 615cf20..05a4fda 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.17.0}" +VERSION="${VERSION:-1.18.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 a385ace..20816a4 100644 --- a/site/index.en.html +++ b/site/index.en.html @@ -160,7 +160,7 @@
- macOS 13+ · v1.17.0 · MIT + macOS 13+ · v1.18.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 +169,7 @@

The editor for text
you didn't write.

記録を返さないので事後に割れない。中継ページは CF がボットを弾いて数えてくれる。 版はここから ?v= で渡す(中継側に版を書くと、リリースのたびに直す場所が増える)。 言語ごとに URL が違うので data-only で出し分ける。 --> - ↓ Download .dmg + ↓ Download .dmg View source
diff --git a/site/index.html b/site/index.html index a385ace..20816a4 100644 --- a/site/index.html +++ b/site/index.html @@ -160,7 +160,7 @@
- macOS 13+ · v1.17.0 · MIT + macOS 13+ · v1.18.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 +169,7 @@

The editor for text
you didn't write.

記録を返さないので事後に割れない。中継ページは CF がボットを弾いて数えてくれる。 版はここから ?v= で渡す(中継側に版を書くと、リリースのたびに直す場所が増える)。 言語ごとに URL が違うので data-only で出し分ける。 --> - ↓ Download .dmg + ↓ Download .dmg View source
diff --git a/site/index.ja.html b/site/index.ja.html index 21951d1..7b7104b 100644 --- a/site/index.ja.html +++ b/site/index.ja.html @@ -155,7 +155,7 @@
- macOS 13+ · v1.17.0 · MIT + macOS 13+ · v1.18.0 · MIT

自分が書いていないテキストを、
調べて、正す。

ログ、CSV、エクスポート、ダンプ、差分。どこかから降ってきたテキストです。だから大きさも文字コードも、どれだけ読みにくいかも、こちらでは選べません。MrEditor は、それを開いて、絞って、桁を揃えて、見比べて、直して、ファイルを半端な状態にせずに保存します。

@@ -165,7 +165,7 @@

自分が書いていないテキストを、
調べ 版はここから ?v= で渡す(中継側に版を書くと、リリースのたびに直す場所が増える)。 言語ごとに URL が違うので data-only で出し分ける。 --> - ↓ .dmg をダウンロード + ↓ .dmg をダウンロード ソースを見る

Apple Developer ID で署名し、Apple の公証を通しています。ダブルクリックするだけで開きます。Apple Silicon / Intel 両対応。

diff --git a/site/releases.html b/site/releases.html index 46789a9..e448915 100644 --- a/site/releases.html +++ b/site/releases.html @@ -5,7 +5,7 @@ MrEditor — release history - + @@ -183,7 +183,7 @@
Release history

44 releases in 84 days.

-

From the first release on 2026-06-26 to v1.17.0 on 9/18. It shipped as a read-only viewer for huge logs, could edit and save them 11 days later, reached 1.0 in 15 days, and then grew comparison, a transform toolbox, AI, and a ruler for counting columns. Every tag, dated.

+

From the first release on 2026-06-26 to v1.18.0 on 9/24. It shipped as a read-only viewer for huge logs, could edit and save them 11 days later, reached 1.0 in 15 days, and then grew comparison, a transform toolbox, AI, and a ruler for counting columns. Every tag, dated.

@@ -506,6 +506,18 @@

What you just copied

v1.17.0 · 9/18
+
+
+

AI that never leaves the room

v1.18.0 · 9/24 +

AI diagnose already kept its promise — your key never left your Mac. But a cloud key still existed somewhere. Anyone who wanted none at all had nowhere to point it.

+
+ +
+ 1.18.09/24 + BYOK now includes local Ollama — no key, and the request never leaves the machine. Also: recent searches in the search bar, and Markdown/major-language syntax highlighting (headings, emphasis, code, links; comments, strings, numbers, keywords for Python/Swift/JS/Shell/YAML/JSON/SQL/CSS/Java/PHP/Go/Ruby/Perl). Fixed: reopening the search bar while filtering left Replace looking enabled when it silently did nothing — the read-only banner now says why +
+
+

About this data

    @@ -519,7 +531,7 @@

    About this data

    diff --git a/site/releases.ja.html b/site/releases.ja.html index 89eab66..37a867f 100644 --- a/site/releases.ja.html +++ b/site/releases.ja.html @@ -5,7 +5,7 @@ MrEditor — リリース全史 - + @@ -180,7 +180,7 @@
    リリース全史

    84日で44リリース。

    -

    2026年6月26日の初リリースから、9月18日の v1.17.0 まで。巨大なログを読むだけのビューアとして出て、11日後に編集と保存を覚え、15日で 1.0 に着き、そこから比較・変換ツールボックス・AI・桁を数えるルーラーが乗るまでの全記録です。

    +

    2026年6月26日の初リリースから、9月24日の v1.18.0 まで。巨大なログを読むだけのビューアとして出て、11日後に編集と保存を覚え、15日で 1.0 に着き、そこから比較・変換ツールボックス・AI・桁を数えるルーラーが乗るまでの全記録です。

    @@ -503,6 +503,18 @@

    さっきコピーしたもの

    v1.17.0 · 9/18
+
+
+

部屋から出ないAI

v1.18.0 · 9/24 +

AI診断は「鍵は自分の Mac から出さない」と約束していた。でもそれは、クラウドの鍵がどこかには存在する、という意味でもある。鍵そのものを持ちたくない人には、送り先が無かった。

+
+ +
+ 1.18.09/24 + BYOK にローカルの Ollama を追加——鍵は不要で、リクエストも機外に出ない。あわせて検索バーに最近の検索、Markdown/主要言語の構文ハイライト(見出し・強調・コード・リンク/コメント・文字列・数値・予約語、対応 Python/Swift/JS/Shell/YAML/JSON/SQL/CSS/Java/PHP/Go/Ruby/Perl)。フィルタ中に検索バーを開き直すと置換ボタンが押せる見た目のまま実は効かない不具合も修正——読み取り専用バナーが理由を表示する +
+
+

データについて

    @@ -517,7 +529,7 @@

    データについて

    diff --git a/web/lp.src.html b/web/lp.src.html index 1355b78..0562731 100644 --- a/web/lp.src.html +++ b/web/lp.src.html @@ -161,7 +161,7 @@
    - macOS 13+ · v1.17.0 · MIT + macOS 13+ · v1.18.0 · MIT

    - - diff --git a/web/releases.src.html b/web/releases.src.html index 5cba13f..0273a5c 100644 --- a/web/releases.src.html +++ b/web/releases.src.html @@ -12,7 +12,7 @@ - + @@ -186,8 +186,8 @@

    + data-en="From the first release on 2026-06-26 to v1.18.0 on 9/24. It shipped as a read-only viewer for huge logs, could edit and save them 11 days later, reached 1.0 in 15 days, and then grew comparison, a transform toolbox, AI, and a ruler for counting columns. Every tag, dated." + data-ja="2026年6月26日の初リリースから、9月24日の v1.18.0 まで。巨大なログを読むだけのビューアとして出て、11日後に編集と保存を覚え、15日で 1.0 に着き、そこから比較・変換ツールボックス・AI・桁を数えるルーラーが乗るまでの全記録です。">

    @@ -568,6 +568,20 @@

+
+
+

v1.18.0 · 9/24 +

+
+ +
+ 1.18.09/24 + +
+
+

    @@ -586,10 +600,10 @@

    - - + +