Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 が
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/MrEditorCore/AppInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down
64 changes: 56 additions & 8 deletions Sources/MrEditorCore/Core/TabularFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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..<columns.count).contains(toIndex) else { return self }
var cols = columns
let col = cols.remove(at: index)
cols.insert(col, at: toIndex)
return TabularFormatter(mode: mode, columns: cols, fields: fields)
}

/// 列名(key)で対応付けて、並び順と幅をまとめて適用する(Pro のビュープリセットが使う)。
///
/// `order` に無いキー(想定外=ファイル形状が変わっている)は**末尾に元の順で残す**
/// ── データを失わない。`order` にあるが現在の列に無いキーは無視する。
func applyingLayout(order: [String], widths: [String: Int]) -> 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)
}

Expand Down Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -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 に委譲するため、ここへは来ない(保険で空列)。
Expand All @@ -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()) }
Expand Down
18 changes: 18 additions & 0 deletions Sources/MrEditorCore/MainWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)。どちらも掴んで動かせる。
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading