chocomint 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43fbe75bcf172909a002314dbbd673417c25e2ab667f3e401eacb026988743e4
4
- data.tar.gz: 68e6a6bf6ecd91251f5311a6c03742353e17ac4689c51b64cd35fe1d602e80f2
3
+ metadata.gz: 691a0248c4e919afa5640c7398b3f1b06221c397ac2c1e4c73fb56c044174246
4
+ data.tar.gz: 5371ad706155faff73120e8064f6629b7a776099e7c66fc5f07a89bfec932799
5
5
  SHA512:
6
- metadata.gz: ec1a8f844e984a0387bd6d2a22d66af477b99c6d99320904cc870a2bc115b7b9a3dd21135cab14a966920634f957f9b65657b1abe376d05c28b6e28a113408da
7
- data.tar.gz: 9b8726ba1954c4eded4eaf811ed58c6c6db2ecb239286cb85b98be6263aefe11cdbea98e017a90b8a843495834ae385690b7cc0d3e0ccd00299c2f56b4e2846d
6
+ metadata.gz: 75558e5f77a42aaaddaa2ffdfc5a509975cf7660ae5eaf346d9e5e24d45ce3873783f23e924b5516a6341db2f405e3ac1bd3688bf7677cc9fe0ec0074a5a352c
7
+ data.tar.gz: 0b43f17a5b4f916798db9107c3cef61d21f07da1299723a3feb1e31de43231f521bfc8e4bdfc560ff6dd1b65f9546819b61fb79e9263c90ef89796984cac48c9
data/README.md CHANGED
@@ -191,11 +191,37 @@ ruby bin/chocomint-server
191
191
  |----------------|------|
192
192
  | `GET /edit` | エディタ画面 |
193
193
  | `GET /edit/files` | `workspace/` のファイル一覧 (JSON) |
194
- | `GET /edit/file?path=` | ファイル内容 (JSON) |
194
+ | `GET /edit/file?path=` | ファイル内容 (JSON)。画像/PDF/動画/音声は `media` 種別だけ返す |
195
+ | `GET /edit/raw?path=` | 生ファイル配信(メディアプレビュー用。`Range` 対応で動画のシークも可) |
196
+ | `GET /edit/archive?path=` | アーカイブの第一階層エントリ + メタデータ(zip / tar / tar.gz / bz2 等) |
195
197
  | `POST /edit/save` | 人手編集の保存(`PathGuard` で `workspace/` 内に限定) |
196
198
  | `POST /edit/chat` | 自然言語指示で AI 編集(`Planner#run`。`expectations` に指示を渡すため意味的検証が効く) |
197
199
  | `GET /edit/assets/*` | Monaco / xterm.js のローカル同梱アセット |
198
200
 
201
+ ### メディアプレビュー
202
+
203
+ テキストファイルは Monaco で編集し、以下のメディアはタブ内でプレビューする
204
+ (`workspace/` 配下のみ・`PathGuard` で保護):
205
+
206
+ - **画像**: PNG / JPEG / GIF / WebP / BMP / ICO / AVIF / TIFF / SVG(ベクター)
207
+ - **文書**: PDF(ブラウザ内蔵ビューアで表示)
208
+ - **動画**: MP4 / WebM / MKV / MOV / AVI / OGV(`<video>`。ブラウザが対応する形式のみ再生可)
209
+ - **音声**: MP3 / WAV / OGG / M4A / AAC / FLAC / Opus(`<audio>`)
210
+
211
+ 動画・音声は `Range` リクエストに対応し、シークやストリーミング再生ができる。
212
+ ブラウザがコーデック非対応の形式(MKV の一部など)は再生できずメッセージを表示する。
213
+
214
+ ### アーカイブプレビュー
215
+
216
+ zip / tar 系アーカイブをクリックすると、**第一階層(トップレベル)のエントリと
217
+ メタデータ**(エントリ数・展開後サイズ・圧縮サイズ)をタブ内に表示する。展開は
218
+ サーバー側でメモリ上に行い、ディスクには書き出さない。
219
+
220
+ - **対応**: `.zip` / `.tar` / `.tar.gz`(`.tgz`)/ `.gz` / `.tar.bz2`(`.tbz2`)/ `.bz2`
221
+ - 配下のエントリは親ディレクトリに畳み、その配下件数を添えて表示する。
222
+ - `.zip` は中央ディレクトリを自前パースするため rubyzip 非依存。`.bz2` は純Ruby
223
+ 実装の `rbzip2` で展開する(ネイティブ `libbz2` 不要)。
224
+
199
225
  ### 追加依存
200
226
 
201
227
  - `ffi` — Windows の擬似コンソール (ConPTY) を FFI で叩く(プリビルド gem、DevKit 不要)。
@@ -41,5 +41,6 @@ AI エディタ (`/edit`) をオフラインで動作させるため、以下を
41
41
  | webrick | Ruby / BSD-2-Clause | https://github.com/ruby/webrick |
42
42
  | ffi | BSD-3-Clause | https://github.com/ffi/ffi |
43
43
  | websocket-driver | Apache-2.0 | https://github.com/faye/websocket-driver-ruby |
44
+ | rbzip2 | BSD-3-Clause | https://github.com/koraktor/rbzip2 |
44
45
 
45
46
  上記はいずれも再配布・商用利用を許容する寛容なライセンスである。
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "stringio"
6
+ require "rubygems/package"
7
+
8
+ module Chocomint
9
+ # /edit AI エディタのアーカイブプレビュー用ハンドラ (Server に include して使う)。
10
+ #
11
+ # 提供機能:
12
+ # GET /edit/archive?path=foo.zip アーカイブの第一階層エントリ + メタデータ (JSON)
13
+ #
14
+ # 対応形式: .zip / .tar / .tar.gz (.tgz) / .gz / .tar.bz2 (.tbz2) / .bz2
15
+ # いずれも展開はメモリ上で行い、ディスクには書き出さない。tar/zip はエントリを
16
+ # ストリーム走査し、第一階層 (トップレベル) だけに畳んで返す。
17
+ #
18
+ # 対応形式は拡張子でしか判定しない (中身の magic は見ない)。ファイルアクセスは
19
+ # すべて @path_guard 経由で workspace 内に制限する。
20
+ module EditArchiveHandlers
21
+ # 一覧をブラウザで扱える範囲に抑えるためのエントリ数上限 (走査自体の安全弁)。
22
+ # tar は全走査するため、巨大アーカイブでも到達したら打ち切って capped を立てる。
23
+ MAX_ARCHIVE_ENTRIES = 20_000
24
+
25
+ # bzip2 展開に許す入力サイズ上限 (純Ruby 実装は重いので極端に巨大な .bz2 は弾く)。
26
+ MAX_BZIP2_INPUT_BYTES = 64 * 1024 * 1024
27
+
28
+ def handle_edit_archive(req, res)
29
+ return edit_method_guard(res) unless req.request_method == "GET"
30
+
31
+ path = req.query["path"].to_s
32
+ return edit_json(res, 400, "error" => "path required") if path.empty?
33
+
34
+ abs = @path_guard.resolve(path)
35
+ return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
36
+
37
+ kind = archive_kind(abs)
38
+ return edit_json(res, 415, "error" => "unsupported archive format") unless kind
39
+
40
+ info = inspect_archive(abs, kind)
41
+ edit_json(res, 200, { "path" => path }.merge(info))
42
+ rescue Chocomint::PathAccessError => e
43
+ edit_json(res, 403, "error" => e.message)
44
+ rescue ArchiveError => e
45
+ # 壊れている/未対応の内部構造など。UI 向けにメッセージを返す (500 にはしない)。
46
+ edit_json(res, 422, "error" => e.message)
47
+ rescue Chocomint::Error => e
48
+ edit_json(res, 500, "error" => e.message)
49
+ end
50
+
51
+ # アーカイブ処理中の想定内エラー (壊れたデータ・展開失敗など)。
52
+ class ArchiveError < StandardError; end
53
+
54
+ # ---- 形式判定 -----------------------------------------------------------
55
+
56
+ # 拡張子からアーカイブ形式を判定して種別文字列を返す (非対応は nil)。
57
+ # 種別: "zip" / "tar" / "tar.gz" / "tar.bz2" / "gz" / "bz2"
58
+ # 二重拡張子 (.tar.gz 等) を単一拡張子 (.gz) より優先する。
59
+ def archive_kind(abs)
60
+ lower = File.basename(abs).downcase
61
+ return "tar.gz" if lower.end_with?(".tar.gz")
62
+ return "tar.gz" if lower.end_with?(".tgz")
63
+ return "tar.bz2" if lower.end_with?(".tar.bz2")
64
+ return "tar.bz2" if lower.end_with?(".tbz2") || lower.end_with?(".tbz")
65
+ return "zip" if lower.end_with?(".zip")
66
+ return "tar" if lower.end_with?(".tar")
67
+ return "gz" if lower.end_with?(".gz")
68
+ return "bz2" if lower.end_with?(".bz2")
69
+
70
+ nil
71
+ end
72
+
73
+ # フロントがメディア/アーカイブを区別できるよう、handle_edit_file からも使う判定。
74
+ def archive?(abs)
75
+ !archive_kind(abs).nil?
76
+ end
77
+
78
+ # ---- 検査 (種別ごとにディスパッチ) --------------------------------------
79
+
80
+ def inspect_archive(abs, kind)
81
+ case kind
82
+ when "zip" then inspect_zip(abs)
83
+ when "tar" then inspect_tar(File.binread(abs))
84
+ when "tar.gz" then inspect_tar(gunzip(File.binread(abs)))
85
+ when "tar.bz2" then inspect_tar(bunzip2(abs))
86
+ when "gz" then inspect_single_compressed(abs, "gzip", strip_ext: ".gz")
87
+ when "bz2" then inspect_single_compressed(abs, "bzip2", strip_ext: ".bz2")
88
+ else
89
+ raise ArchiveError, "unsupported archive format"
90
+ end
91
+ end
92
+
93
+ # ---- tar の走査 ---------------------------------------------------------
94
+ #
95
+ # 展開済み tar バイト列を走査し、第一階層に畳んだ entries とメタデータを返す。
96
+ def inspect_tar(tar_bytes)
97
+ dirs = {} # トップレベルのディレクトリ名 => 配下エントリ数
98
+ files = [] # トップレベルの直下ファイル { name, size }
99
+ total = 0
100
+ total_size = 0
101
+ capped = false
102
+
103
+ Gem::Package::TarReader.new(StringIO.new(tar_bytes)) do |reader|
104
+ reader.each do |entry|
105
+ name = entry.full_name.to_s
106
+ next if name.empty? || name == "./"
107
+
108
+ total += 1
109
+ if total > MAX_ARCHIVE_ENTRIES
110
+ capped = true
111
+ break
112
+ end
113
+
114
+ rel = normalize_entry_name(name)
115
+ next if rel.empty?
116
+
117
+ size = entry.header.size.to_i
118
+ total_size += size unless entry.directory?
119
+ accumulate_top_level(rel, entry.directory?, size, dirs, files)
120
+ end
121
+ end
122
+
123
+ build_result(dirs, files, total, total_size, capped)
124
+ rescue Gem::Package::TarInvalidError, Zlib::GzipFile::Error => e
125
+ raise ArchiveError, "tar を読み取れませんでした: #{e.message}"
126
+ end
127
+
128
+ # ---- zip の走査 (中央ディレクトリを自前パース) --------------------------
129
+ #
130
+ # 標準ライブラリだけで済ませるため rubyzip には依存せず、ZIP の End Of Central
131
+ # Directory (EOCD) → Central Directory を辿ってエントリ名とサイズを読む。
132
+ # 実データ (ローカルファイルヘッダ) は読まないので大きな zip でも軽い。
133
+ def inspect_zip(abs)
134
+ raw = File.binread(abs)
135
+ cd = zip_central_directory(raw)
136
+
137
+ dirs = {}
138
+ files = []
139
+ total = 0
140
+ total_size = 0
141
+ capped = false
142
+
143
+ cd.each do |ent|
144
+ total += 1
145
+ if total > MAX_ARCHIVE_ENTRIES
146
+ capped = true
147
+ break
148
+ end
149
+ rel = normalize_entry_name(ent[:name])
150
+ next if rel.empty?
151
+
152
+ total_size += ent[:size] unless ent[:dir]
153
+ accumulate_top_level(rel, ent[:dir], ent[:size], dirs, files)
154
+ end
155
+
156
+ build_result(dirs, files, total, total_size, capped)
157
+ end
158
+
159
+ # ZIP の中央ディレクトリを読み、各エントリの { name, size, dir } を返す。
160
+ def zip_central_directory(raw)
161
+ eocd = find_eocd(raw)
162
+ raise ArchiveError, "zip の終端レコードが見つかりません" unless eocd
163
+
164
+ count = raw[eocd + 10, 2].unpack1("v")
165
+ offset = raw[eocd + 16, 4].unpack1("V")
166
+ entries = []
167
+ pos = offset
168
+
169
+ count.times do
170
+ break if pos + 46 > raw.bytesize
171
+ sig = raw[pos, 4]
172
+ break unless sig == "PK\x01\x02".b # Central Directory File Header
173
+
174
+ usize = raw[pos + 24, 4].unpack1("V") # uncompressed size
175
+ name_len = raw[pos + 28, 2].unpack1("v")
176
+ extra_len = raw[pos + 30, 2].unpack1("v")
177
+ comment_len = raw[pos + 32, 2].unpack1("v")
178
+ name = raw[pos + 46, name_len].to_s
179
+ name = zip_decode_name(name)
180
+ entries << { name: name, size: usize, dir: name.end_with?("/") }
181
+ pos += 46 + name_len + extra_len + comment_len
182
+ end
183
+
184
+ entries
185
+ end
186
+
187
+ # EOCD (End Of Central Directory, シグネチャ PK\x05\x06) を末尾から探す。
188
+ # ZIP コメントは最大 65535 バイトなので、末尾 (22 + 65535) だけ後方走査すれば十分。
189
+ def find_eocd(raw)
190
+ sig = "PK\x05\x06".b
191
+ min = [raw.bytesize - (22 + 0xFFFF), 0].max
192
+ i = raw.bytesize - 22
193
+ while i >= min
194
+ return i if raw[i, 4] == sig
195
+
196
+ i -= 1
197
+ end
198
+ nil
199
+ end
200
+
201
+ # zip のエントリ名を文字列に整える。UTF-8 として妥当ならそれ、駄目なら置換して壊さない。
202
+ def zip_decode_name(name)
203
+ s = name.dup.force_encoding("UTF-8")
204
+ s.valid_encoding? ? s : s.scrub("?")
205
+ end
206
+
207
+ # ---- 単体圧縮ファイル (.gz / .bz2、tar でない) --------------------------
208
+ #
209
+ # tar を含まない単なる圧縮ファイルは、展開後の 1 ファイルとして扱う。
210
+ # 中身の全展開は避け、メタデータ (圧縮/展開サイズ・推定名) だけを返す。
211
+ def inspect_single_compressed(abs, algorithm, strip_ext:)
212
+ compressed_size = File.size(abs)
213
+ inner_name = File.basename(abs)
214
+ inner_name = inner_name[0...-strip_ext.length] if inner_name.downcase.end_with?(strip_ext)
215
+
216
+ uncompressed_size =
217
+ case algorithm
218
+ when "gzip" then gzip_uncompressed_size(abs)
219
+ when "bzip2" then bunzip2(abs).bytesize
220
+ end
221
+
222
+ {
223
+ "archive" => algorithm == "gzip" ? "gz" : "bz2",
224
+ "single" => true,
225
+ "entries" => [{ "name" => inner_name, "dir" => false, "size" => uncompressed_size }],
226
+ "total_entries" => 1,
227
+ "total_size" => uncompressed_size,
228
+ "compressed_size" => compressed_size,
229
+ "capped" => false
230
+ }
231
+ end
232
+
233
+ # ---- 展開ヘルパ ---------------------------------------------------------
234
+
235
+ def gunzip(bytes)
236
+ Zlib::GzipReader.new(StringIO.new(bytes)).read
237
+ rescue Zlib::GzipFile::Error => e
238
+ raise ArchiveError, "gzip を展開できませんでした: #{e.message}"
239
+ end
240
+
241
+ # gzip 末尾 4 バイトの ISIZE (展開後サイズ mod 2^32)。4GB 未満の実用ケース向け。
242
+ def gzip_uncompressed_size(abs)
243
+ tail = File.open(abs, "rb") { |f| f.seek(-4, IO::SEEK_END); f.read(4) }
244
+ tail ? tail.unpack1("V") : nil
245
+ rescue SystemCallError
246
+ nil
247
+ end
248
+
249
+ # bzip2 を純Ruby (rbzip2) で展開する。ネイティブ libbz2 は不要。
250
+ def bunzip2(abs)
251
+ require "rbzip2"
252
+ raw = File.binread(abs)
253
+ if raw.bytesize > MAX_BZIP2_INPUT_BYTES
254
+ raise ArchiveError, "bzip2 ファイルが大きすぎます (上限 #{MAX_BZIP2_INPUT_BYTES / (1024 * 1024)}MB)"
255
+ end
256
+
257
+ RBzip2.default_adapter::Decompressor.new(StringIO.new(raw)).read
258
+ rescue LoadError
259
+ raise ArchiveError, "bzip2 の展開には rbzip2 gem が必要です"
260
+ rescue ArchiveError
261
+ raise
262
+ rescue StandardError => e
263
+ raise ArchiveError, "bzip2 を展開できませんでした: #{e.message}"
264
+ end
265
+
266
+ # ---- 集計の共通処理 -----------------------------------------------------
267
+
268
+ # エントリ名を正規化する ("./" 前置や重複スラッシュを除く。先頭は相対に統一)。
269
+ def normalize_entry_name(name)
270
+ s = name.tr("\\", "/")
271
+ s = s.sub(%r{\A\./}, "")
272
+ s = s.sub(%r{\A/+}, "")
273
+ s.gsub(%r{/+}, "/")
274
+ end
275
+
276
+ # 正規化済みエントリを「第一階層」に畳んで dirs / files に足し込む。
277
+ # "src/main.rb" → トップレベル dir "src" を登録し配下件数 +1
278
+ # "README.md" → トップレベル直下 file "README.md"
279
+ # "src/" → dir "src" を登録するだけ (ディレクトリ自身なので件数は増やさない)
280
+ # "emptydir/" → dir "emptydir" を件数 0 で登録 (空ディレクトリ)
281
+ def accumulate_top_level(rel, is_dir, size, dirs, files)
282
+ # 末尾スラッシュはディレクトリ自身のエントリ。区切りとしての "/" と区別する。
283
+ trailing_dir = rel.end_with?("/")
284
+ trimmed = trailing_dir ? rel.chomp("/") : rel
285
+ slash = trimmed.index("/")
286
+
287
+ if slash.nil?
288
+ # トップレベル直下のエントリ (ファイル or ディレクトリ自身)。
289
+ if is_dir || trailing_dir
290
+ dirs[trimmed] ||= 0 # 件数は増やさない (自身の登録だけ)。
291
+ else
292
+ files << { name: trimmed, size: size }
293
+ end
294
+ else
295
+ # 配下エントリ → トップレベルのディレクトリに畳んで件数を +1。
296
+ top = trimmed[0...slash]
297
+ dirs[top] ||= 0
298
+ dirs[top] += 1
299
+ end
300
+ end
301
+
302
+ # dirs / files から JSON 用の結果ハッシュを組み立てる。
303
+ # entries はディレクトリ→ファイルの順、各名前順 (一覧の並びに揃える)。
304
+ def build_result(dirs, files, total, total_size, capped)
305
+ dir_entries = dirs.keys.sort.map do |name|
306
+ { "name" => name, "dir" => true, "child_count" => dirs[name] }
307
+ end
308
+ # 同名ファイルの重複は稀だが、名前順で安定させる。
309
+ file_entries = files.uniq { |f| f[:name] }
310
+ .sort_by { |f| f[:name] }
311
+ .map { |f| { "name" => f[:name], "dir" => false, "size" => f[:size] } }
312
+
313
+ {
314
+ "archive" => "tar_or_zip",
315
+ "single" => false,
316
+ "entries" => dir_entries + file_entries,
317
+ "total_entries" => total,
318
+ "total_size" => total_size,
319
+ "capped" => capped
320
+ }
321
+ end
322
+ end
323
+ end
@@ -110,6 +110,18 @@ module Chocomint
110
110
 
111
111
  abs = @path_guard.resolve(path)
112
112
  return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
113
+
114
+ # 画像 / PDF / 動画 / 音声は拡張子で先に判定し、中身を読まずにメディア種別だけ返す
115
+ # (フロントは /edit/raw で取得する)。SVG のようにテキストでもある形式もここで拾う。
116
+ # メディアは巨大化しやすい (動画等) ため @max_file_bytes の上限は掛けない。
117
+ media = media_kind(abs)
118
+ return edit_json(res, 200, "path" => path, "media" => media) if media
119
+
120
+ # zip / tar / tar.gz / bz2 等のアーカイブは第一階層一覧を表示する専用ビューに回す
121
+ # (フロントは /edit/archive で取得する)。中身の展開はここでは行わない。
122
+ return edit_json(res, 200, "path" => path, "archive" => true) if archive?(abs)
123
+
124
+ # テキストとして開くものだけサイズ上限を掛ける (Monaco に丸ごと載せるため)。
113
125
  if File.size(abs) > @max_file_bytes
114
126
  return edit_json(res, 413, "error" => "file too large")
115
127
  end
@@ -127,6 +139,126 @@ module Chocomint
127
139
  edit_json(res, 500, "error" => e.message)
128
140
  end
129
141
 
142
+ # ---- 生ファイル配信 (画像 / PDF / 動画 / 音声のプレビュー用) ---------------
143
+ #
144
+ # GET /edit/raw?path=foo.png
145
+ #
146
+ # workspace 内のファイルを、拡張子から推定した Content-Type でそのまま返す。
147
+ # <img> / <video> / <audio> / <iframe> から参照される。テキストと違い巨大化しやすい
148
+ # (動画等) ため @max_file_bytes の上限は掛けず、Range リクエストに応じて部分配信もする
149
+ # (動画のシークや音声再生に必要)。
150
+ def handle_edit_raw(req, res)
151
+ return edit_method_guard(res) unless req.request_method == "GET"
152
+
153
+ path = req.query["path"].to_s
154
+ return edit_json(res, 400, "error" => "path required") if path.empty?
155
+
156
+ abs = @path_guard.resolve(path)
157
+ return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
158
+
159
+ content_type = media_content_type(abs)
160
+ res["cache-control"] = "no-cache"
161
+ res["accept-ranges"] = "bytes"
162
+ serve_raw_file(req, res, abs, content_type)
163
+ rescue Chocomint::PathAccessError => e
164
+ edit_json(res, 403, "error" => e.message)
165
+ rescue Chocomint::Error => e
166
+ edit_json(res, 500, "error" => e.message)
167
+ end
168
+
169
+ # 拡張子から Content-Type を推定する。未知の拡張子は octet-stream。
170
+ RAW_CONTENT_TYPES = {
171
+ # 画像 (ラスター)
172
+ ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
173
+ ".gif" => "image/gif", ".webp" => "image/webp", ".bmp" => "image/bmp",
174
+ ".ico" => "image/x-icon", ".avif" => "image/avif", ".apng" => "image/apng",
175
+ ".tif" => "image/tiff", ".tiff" => "image/tiff",
176
+ # 画像 (ベクター)
177
+ ".svg" => "image/svg+xml",
178
+ # 文書
179
+ ".pdf" => "application/pdf",
180
+ # 動画
181
+ ".mp4" => "video/mp4", ".m4v" => "video/mp4", ".webm" => "video/webm",
182
+ ".ogv" => "video/ogg", ".mkv" => "video/x-matroska", ".mov" => "video/quicktime",
183
+ ".avi" => "video/x-msvideo",
184
+ # 音声
185
+ ".mp3" => "audio/mpeg", ".wav" => "audio/wav", ".ogg" => "audio/ogg",
186
+ ".oga" => "audio/ogg", ".m4a" => "audio/mp4", ".aac" => "audio/aac",
187
+ ".flac" => "audio/flac", ".opus" => "audio/opus", ".weba" => "audio/webm"
188
+ }.freeze
189
+
190
+ def media_content_type(abs)
191
+ RAW_CONTENT_TYPES.fetch(File.extname(abs).downcase, "application/octet-stream")
192
+ end
193
+
194
+ # 拡張子 → メディア種別 ("image" / "pdf" / "video" / "audio")。フロントはこの値で
195
+ # 表示ウィジェット (<img> / <iframe> / <video> / <audio>) を選ぶ。非メディアは nil。
196
+ MEDIA_KINDS = {
197
+ "image" => %w[.png .jpg .jpeg .gif .webp .bmp .ico .avif .apng .tif .tiff .svg],
198
+ "pdf" => %w[.pdf],
199
+ "video" => %w[.mp4 .m4v .webm .ogv .mkv .mov .avi],
200
+ "audio" => %w[.mp3 .wav .ogg .oga .m4a .aac .flac .opus .weba]
201
+ }.freeze
202
+
203
+ # 拡張子 (小文字) → メディア種別の逆引き表。
204
+ MEDIA_KIND_BY_EXT = MEDIA_KINDS.each_with_object({}) do |(kind, exts), h|
205
+ exts.each { |ext| h[ext] = kind }
206
+ end.freeze
207
+
208
+ def media_kind(abs)
209
+ MEDIA_KIND_BY_EXT[File.extname(abs).downcase]
210
+ end
211
+
212
+ # ファイルを配信する。Range ヘッダがあれば 206 Partial Content で部分配信する
213
+ # (動画のシーク・一部ブラウザの音声/動画再生に必須)。範囲不正なら 416。
214
+ def serve_raw_file(req, res, abs, content_type)
215
+ size = File.size(abs)
216
+ range = parse_byte_range(req["range"], size)
217
+
218
+ if range.nil? && req["range"].to_s.strip != ""
219
+ # Range 指定はあるが解釈できない → 416 で全長を知らせる。
220
+ res.status = 416
221
+ res["content-range"] = "bytes */#{size}"
222
+ res.body = ""
223
+ return
224
+ end
225
+
226
+ res["content-type"] = content_type
227
+ if range
228
+ first, last = range
229
+ res.status = 206
230
+ res["content-range"] = "bytes #{first}-#{last}/#{size}"
231
+ res["content-length"] = (last - first + 1).to_s
232
+ res.body = File.open(abs, "rb") { |f| f.seek(first); f.read(last - first + 1) }
233
+ else
234
+ res.status = 200
235
+ res["content-length"] = size.to_s
236
+ res.body = File.binread(abs)
237
+ end
238
+ end
239
+
240
+ # "bytes=first-last" 形式の Range を [first, last] に解釈する (単一範囲のみ対応)。
241
+ # 範囲を持たない/不正/範囲外なら nil を返す。"bytes=500-" や "bytes=-500" にも対応。
242
+ def parse_byte_range(header, size)
243
+ return nil if header.nil? || size.zero?
244
+
245
+ m = /\Abytes=(\d*)-(\d*)\z/.match(header.strip)
246
+ return nil unless m
247
+
248
+ first_s, last_s = m[1], m[2]
249
+ if first_s.empty? && last_s.empty?
250
+ nil
251
+ elsif first_s.empty?
252
+ # 末尾 N バイト。
253
+ n = last_s.to_i
254
+ n.zero? ? nil : [[size - n, 0].max, size - 1]
255
+ else
256
+ first = first_s.to_i
257
+ last = last_s.empty? ? size - 1 : [last_s.to_i, size - 1].min
258
+ (first <= last && first < size) ? [first, last] : nil
259
+ end
260
+ end
261
+
130
262
  # テキストとして扱えないか判定する。NUL バイトを含む、または先頭ブロックが
131
263
  # 妥当な UTF-8 でないものはバイナリ (エディタで開けない) とみなす。
132
264
  BINARY_SNIFF_BYTES = 8192
@@ -155,7 +287,9 @@ module Chocomint
155
287
 
156
288
  abs = @path_guard.resolve(path)
157
289
  FileUtils.mkdir_p(File.dirname(abs))
158
- File.write(abs, content)
290
+ # バイナリモードで書き込む。Windows の既定 (テキストモード) だと \n が \r\n に
291
+ # 変換され、クライアントが選んだ改行コード (CRLF/LF) を無視して常に CRLF になってしまう。
292
+ File.binwrite(abs, content)
159
293
  edit_json(res, 200, "path" => path, "bytes" => content.bytesize)
160
294
  rescue JSON::ParserError => e
161
295
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
@@ -8,6 +8,7 @@ require_relative "errors"
8
8
  require_relative "logger/sqlite_logger"
9
9
  require_relative "edit_handlers"
10
10
  require_relative "edit_git_handlers"
11
+ require_relative "edit_archive_handlers"
11
12
 
12
13
  module Chocomint
13
14
  # 監督完結型の Anthropic Messages API 互換プロキシサーバー。
@@ -20,6 +21,7 @@ module Chocomint
20
21
  class Server
21
22
  include EditHandlers
22
23
  include EditGitHandlers
24
+ include EditArchiveHandlers
23
25
 
24
26
  # planner: Planner インスタンス (run(request, expectations:) -> Planner::Result)。
25
27
  # edit UI 用に PathGuard / base_dir / vendor / console 情報も受け取る (任意)。
@@ -63,6 +65,8 @@ module Chocomint
63
65
  server.mount_proc("/edit/static") { |req, res| handle_edit_static(req, res) }
64
66
  server.mount_proc("/edit/dir") { |req, res| handle_edit_dir(req, res) }
65
67
  server.mount_proc("/edit/file") { |req, res| handle_edit_file(req, res) }
68
+ server.mount_proc("/edit/raw") { |req, res| handle_edit_raw(req, res) }
69
+ server.mount_proc("/edit/archive") { |req, res| handle_edit_archive(req, res) }
66
70
  server.mount_proc("/edit/save") { |req, res| handle_edit_save(req, res) }
67
71
  server.mount_proc("/edit/chat") { |req, res| handle_edit_chat(req, res) }
68
72
  server.mount_proc("/edit/compact") { |req, res| handle_edit_compact(req, res) }
data/lib/chocomint.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chocomint
4
- VERSION = "1.0.3"
4
+ VERSION = "1.1.0"
5
5
  end
6
6
 
7
7
  require_relative "chocomint/errors"
data/public/edit/edit.css CHANGED
@@ -102,8 +102,11 @@ html, body { margin: 0; height: 100%;
102
102
  #files-actions .menu-icon-btn:hover { background: #dcecee; }
103
103
  #file-list { overflow: auto; flex: 1 1 auto; min-height: 3rem; }
104
104
  #files ul { list-style: none; margin: 0; padding: 0; }
105
- #files ul ul { display: none; padding-left: 0.9rem; } /* 折りたたみ + 階層インデント */
106
- #files li.dir.open > ul { display: block; }
105
+ /* 子ディレクトリの <ul> class="children"。ルートは #file-list (div) 直下に
106
+ <li> を直接並べる (renderRoot が ul 自体を作らない) ため、入れ子 "ul ul" では
107
+ マッチしない。children クラスを直接ターゲットにして折りたたみ + 階層インデントを効かせる。 */
108
+ #files ul.children { display: none; padding-left: 0.9rem; }
109
+ #files li.dir.open > ul.children { display: block; }
107
110
  #files .row { display: flex; align-items: center; gap: 0.2rem;
108
111
  padding: 0.25rem 0.7rem; font-size: 0.83rem; cursor: pointer;
109
112
  min-width: 0; white-space: nowrap; overflow: hidden; }
@@ -173,6 +176,12 @@ html, body { margin: 0; height: 100%;
173
176
  cursor: pointer; border-radius: 4px; }
174
177
  #statusbar-branch:hover { background: rgba(255,255,255,0.15); }
175
178
  .statusbar-branch-icon { font-size: 0.85rem; }
179
+ /* ブランチ (左) と改行コード (右) を両端に寄せるための伸縮スペーサー。 */
180
+ .statusbar-spacer { flex: 1; }
181
+ #statusbar-eol { background: transparent; border: none; color: inherit; font-size: inherit;
182
+ padding: 0.2rem 0.6rem; cursor: pointer; border-radius: 4px; }
183
+ #statusbar-eol[hidden] { display: none; }
184
+ #statusbar-eol:hover { background: rgba(255,255,255,0.15); }
176
185
  /* ブランチ切り替えダイアログの一覧。 */
177
186
  .branch-list { list-style: none; margin: 0 0 0.7rem; padding: 0; max-height: 12rem;
178
187
  overflow: auto; border: 1px solid #eee; border-radius: 6px; }
@@ -188,6 +197,63 @@ html, body { margin: 0; height: 100%;
188
197
  #editor { flex: 1; min-height: 0; min-width: 0; }
189
198
  /* 開いているタブが無いときは Monaco を隠し、代わりにプレースホルダーを見せる。 */
190
199
  #app.no-tabs #editor { display: none; }
200
+ /* メディアプレビュー領域: 画像 / PDF / 動画 / 音声を表示する。
201
+ .show-media のときだけ表示し、その間は Monaco (#editor) を隠す。 */
202
+ #media-view { display: none; flex: 1; min-height: 0; min-width: 0;
203
+ overflow: auto; background: #f6f6f6;
204
+ align-items: center; justify-content: center; padding: 1rem; }
205
+ #app.show-media #editor { display: none; }
206
+ #app.show-media #media-view { display: flex; }
207
+ /* 画像: 領域に収まるよう縮小しつつ、原寸以上には拡大しない。市松模様で透過を分かりやすく。 */
208
+ #media-view img.media-image { max-width: 100%; max-height: 100%; object-fit: contain;
209
+ background-image:
210
+ linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
211
+ linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
212
+ linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
213
+ linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
214
+ background-size: 20px 20px;
215
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0;
216
+ box-shadow: 0 1px 6px rgba(0,0,0,0.2); }
217
+ /* PDF: iframe を領域いっぱいに広げる (padding 無しで縁まで使う)。 */
218
+ #app.show-media.media-pdf #media-view { padding: 0; align-items: stretch; }
219
+ #media-view iframe.media-pdf { flex: 1; width: 100%; height: 100%; border: none; }
220
+ /* 動画: 領域に収める。 */
221
+ #media-view video.media-video { max-width: 100%; max-height: 100%;
222
+ background: #000; box-shadow: 0 1px 6px rgba(0,0,0,0.2); }
223
+ /* 音声: プレイヤーはファイル名の下に配置する縦積みカード。 */
224
+ #media-view .media-audio-card { display: flex; flex-direction: column; gap: 0.75rem;
225
+ align-items: center; color: #555; }
226
+ #media-view .media-audio-card .media-audio-name { font-size: 0.9rem; word-break: break-all; }
227
+ #media-view audio.media-audio { width: min(28rem, 80vw); }
228
+ /* 読み込み失敗時のメッセージ。 */
229
+ #media-view .media-error { color: #a33; font-size: 0.9rem; text-align: center; }
230
+ /* アーカイブ (zip/tar 等) の第一階層 + メタデータ表示。 */
231
+ #archive-view { display: none; flex: 1; min-height: 0; min-width: 0;
232
+ overflow: auto; background: #fafafa; padding: 1rem 1.25rem; }
233
+ #app.show-archive #editor { display: none; }
234
+ #app.show-archive #archive-view { display: block; }
235
+ .archive-head { display: flex; align-items: baseline; gap: 0.6rem;
236
+ padding-bottom: 0.6rem; margin-bottom: 0.5rem;
237
+ border-bottom: 1px solid #e0e0e0; }
238
+ .archive-head .archive-name { font-size: 1.05rem; font-weight: 600; color: #16393d;
239
+ word-break: break-all; }
240
+ .archive-head .archive-kind { font-size: 0.72rem; font-weight: 600; color: #16393d;
241
+ background: #cdeff3; border-radius: 3px; padding: 0.1rem 0.4rem; }
242
+ .archive-meta { color: #666; font-size: 0.8rem; margin-bottom: 0.75rem;
243
+ display: flex; flex-wrap: wrap; gap: 0.3rem 1rem; }
244
+ .archive-meta .archive-capped { color: #a35; }
245
+ /* エントリ一覧: アイコン + 名前 + 補足 (件数/サイズ) の行。 */
246
+ .archive-list { list-style: none; margin: 0; padding: 0; }
247
+ .archive-list li { display: flex; align-items: center; gap: 0.5rem;
248
+ padding: 0.28rem 0.4rem; border-radius: 4px; font-size: 0.85rem; }
249
+ .archive-list li:hover { background: #eef6f7; }
250
+ .archive-list .a-icon { width: 1.3em; text-align: center; flex-shrink: 0; }
251
+ .archive-list .a-name { flex: 1; min-width: 0; overflow: hidden;
252
+ text-overflow: ellipsis; white-space: nowrap; color: #222; }
253
+ .archive-list .a-sub { flex-shrink: 0; color: #999; font-size: 0.78rem;
254
+ font-variant-numeric: tabular-nums; }
255
+ .archive-empty { color: #999; font-size: 0.85rem; padding: 0.5rem 0; }
256
+ .archive-error { color: #a33; font-size: 0.9rem; padding: 0.5rem 0; }
191
257
  #editor-empty { display: none; flex: 1; align-items: center; justify-content: center;
192
258
  color: #999; font-size: 0.9rem; user-select: none; }
193
259
  #app.no-tabs #editor-empty { display: flex; }
data/public/edit/edit.js CHANGED
@@ -165,12 +165,35 @@ const FILE_ICONS = {
165
165
  jpg: '🖼️',
166
166
  jpeg: '🖼️',
167
167
  gif: '🖼️',
168
+ webp: '🖼️',
169
+ bmp: '🖼️',
168
170
  svg: '🖼️',
169
171
  ico: '🖼️',
172
+ avif: '🖼️',
173
+ tif: '🖼️',
174
+ tiff: '🖼️',
170
175
  pdf: '📕',
176
+ mp4: '🎬',
177
+ m4v: '🎬',
178
+ webm: '🎬',
179
+ mkv: '🎬',
180
+ mov: '🎬',
181
+ avi: '🎬',
182
+ ogv: '🎬',
183
+ mp3: '🎵',
184
+ wav: '🎵',
185
+ ogg: '🎵',
186
+ m4a: '🎵',
187
+ aac: '🎵',
188
+ flac: '🎵',
189
+ opus: '🎵',
171
190
  zip: '📦',
172
191
  gz: '📦',
173
192
  tar: '📦',
193
+ tgz: '📦',
194
+ bz2: '📦',
195
+ tbz: '📦',
196
+ tbz2: '📦',
174
197
  lock: '🔒',
175
198
  gitignore: '🚫',
176
199
  dockerfile: '🐳',
@@ -612,18 +635,22 @@ function removeTab(path) {
612
635
  const wasActive = currentPath === path;
613
636
  if (wasActive && autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
614
637
  openTabs.splice(idx, 1);
615
- tab.model.dispose();
638
+ // メディアタブは Monaco モデルを持たない (model=null)。テキスト/バイナリのみ破棄する。
639
+ if (tab.model) tab.model.dispose();
616
640
  if (wasActive) {
617
641
  currentPath = null;
618
642
  const next = openTabs[idx] || openTabs[idx - 1];
619
643
  if (next) {
620
644
  activateEditorTab(next.path);
621
645
  } else {
646
+ hideMediaView();
647
+ hideArchiveView();
622
648
  editor.setModel(null);
623
649
  document.getElementById('app').classList.add('no-tabs');
624
650
  setPathLabel('(ファイル未選択)');
625
651
  setDirty(false);
626
652
  setSaveStatus('');
653
+ updateEolStatus();
627
654
  document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
628
655
  }
629
656
  }
@@ -677,6 +704,199 @@ function openFileByPath(path) {
677
704
  return openFile(path, li);
678
705
  }
679
706
 
707
+ // ---- メディアプレビュー (画像 / PDF / 動画 / 音声) ----
708
+ // メディアタブがアクティブなときだけ #media-view を表示し、Monaco を隠す。
709
+ // 生ファイルは /edit/raw?path= から取得する (種別ごとの Content-Type / Range 対応)。
710
+
711
+ // 現在 #media-view に載せている <video>/<audio> を止めるために保持する。
712
+ let currentMediaEl = null;
713
+
714
+ function rawUrl(path) {
715
+ return '/edit/raw?path=' + encodeURIComponent(path);
716
+ }
717
+
718
+ function showMediaView(tab) {
719
+ const app = document.getElementById('app');
720
+ const view = document.getElementById('media-view');
721
+ stopCurrentMedia();
722
+ view.innerHTML = '';
723
+ app.classList.add('show-media');
724
+ // PDF は縁まで使いたいので専用クラスで padding を外す (CSS 側で制御)。
725
+ app.classList.toggle('media-pdf', tab.media === 'pdf');
726
+
727
+ const url = rawUrl(tab.path);
728
+ const name = tab.path.split('/').pop();
729
+ let el;
730
+ switch (tab.media) {
731
+ case 'image':
732
+ el = document.createElement('img');
733
+ el.className = 'media-image';
734
+ el.alt = name;
735
+ el.src = url;
736
+ el.onerror = () => showMediaError(view, '画像を読み込めませんでした');
737
+ view.appendChild(el);
738
+ break;
739
+ case 'pdf':
740
+ el = document.createElement('iframe');
741
+ el.className = 'media-pdf';
742
+ el.title = name;
743
+ el.src = url;
744
+ view.appendChild(el);
745
+ break;
746
+ case 'video':
747
+ el = document.createElement('video');
748
+ el.className = 'media-video';
749
+ el.src = url;
750
+ el.controls = true;
751
+ el.onerror = () => showMediaError(view,
752
+ 'この動画はブラウザで再生できない形式の可能性があります (' + name + ')');
753
+ view.appendChild(el);
754
+ currentMediaEl = el;
755
+ break;
756
+ case 'audio': {
757
+ const card = document.createElement('div');
758
+ card.className = 'media-audio-card';
759
+ const label = document.createElement('div');
760
+ label.className = 'media-audio-name';
761
+ label.textContent = '🎵 ' + name;
762
+ el = document.createElement('audio');
763
+ el.className = 'media-audio';
764
+ el.src = url;
765
+ el.controls = true;
766
+ el.onerror = () => showMediaError(view,
767
+ 'この音声はブラウザで再生できない形式の可能性があります (' + name + ')');
768
+ card.appendChild(label);
769
+ card.appendChild(el);
770
+ view.appendChild(card);
771
+ currentMediaEl = el;
772
+ break;
773
+ }
774
+ default:
775
+ showMediaError(view, 'このファイルはプレビューできません');
776
+ }
777
+ }
778
+
779
+ function showMediaError(view, message) {
780
+ const p = document.createElement('div');
781
+ p.className = 'media-error';
782
+ p.textContent = message;
783
+ view.appendChild(p);
784
+ }
785
+
786
+ // 再生中のメディアを止める (タブ切り替え/クローズ時に音が鳴り続けないように)。
787
+ function stopCurrentMedia() {
788
+ if (currentMediaEl) {
789
+ try { currentMediaEl.pause(); } catch (e) { /* 既に破棄済み等は無視 */ }
790
+ currentMediaEl = null;
791
+ }
792
+ }
793
+
794
+ function hideMediaView() {
795
+ const app = document.getElementById('app');
796
+ if (!app.classList.contains('show-media')) return;
797
+ stopCurrentMedia();
798
+ app.classList.remove('show-media', 'media-pdf');
799
+ document.getElementById('media-view').innerHTML = '';
800
+ }
801
+
802
+ // ---- アーカイブプレビュー (zip / tar / tar.gz / bz2 等) ----
803
+ // 第一階層 (トップレベル) のエントリとメタデータを /edit/archive?path= から取得して表示する。
804
+
805
+ // バイト数を人間可読 (KB/MB 等) に整形する。null/未定義は空文字。
806
+ function formatBytes(n) {
807
+ if (n == null || isNaN(n)) return '';
808
+ if (n < 1024) return n + ' B';
809
+ const units = ['KB', 'MB', 'GB', 'TB'];
810
+ let v = n / 1024, i = 0;
811
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
812
+ return (v < 10 ? v.toFixed(1) : Math.round(v)) + ' ' + units[i];
813
+ }
814
+
815
+ // アーカイブ形式のバッジ表示名。
816
+ const ARCHIVE_KIND_LABELS = {
817
+ tar_or_zip: 'ARCHIVE', gz: 'GZIP', bz2: 'BZIP2'
818
+ };
819
+
820
+ async function showArchiveView(tab) {
821
+ const app = document.getElementById('app');
822
+ const view = document.getElementById('archive-view');
823
+ app.classList.add('show-archive');
824
+ const name = tab.path.split('/').pop();
825
+ view.innerHTML = '<div class="archive-empty">読み込み中…</div>';
826
+
827
+ let data;
828
+ try {
829
+ const r = await fetch('/edit/archive?path=' + encodeURIComponent(tab.path));
830
+ data = await r.json();
831
+ if (!r.ok) throw new Error(data.error || 'アーカイブを読み取れませんでした');
832
+ } catch (err) {
833
+ // 取得中にタブが切り替わっていたら描画しない。
834
+ if (currentPath !== tab.path) return;
835
+ view.innerHTML = '';
836
+ view.appendChild(el('div', 'archive-error', '⚠ ' + err.message));
837
+ return;
838
+ }
839
+ if (currentPath !== tab.path) return; // 取得中に別タブへ切り替わっていたら破棄。
840
+ renderArchive(view, name, data);
841
+ }
842
+
843
+ // 単純な要素生成ヘルパ (class と textContent 付き)。
844
+ function el(tag, cls, text) {
845
+ const e = document.createElement(tag);
846
+ if (cls) e.className = cls;
847
+ if (text != null) e.textContent = text;
848
+ return e;
849
+ }
850
+
851
+ function renderArchive(view, name, data) {
852
+ view.innerHTML = '';
853
+
854
+ // ヘッダー (ファイル名 + 形式バッジ)。
855
+ const head = el('div', 'archive-head');
856
+ head.appendChild(el('span', 'archive-name', '📦 ' + name));
857
+ head.appendChild(el('span', 'archive-kind', ARCHIVE_KIND_LABELS[data.archive] || 'ARCHIVE'));
858
+ view.appendChild(head);
859
+
860
+ // メタデータ行 (エントリ数 / 合計サイズ / 圧縮サイズ)。
861
+ const meta = el('div', 'archive-meta');
862
+ const entryCount = data.total_entries != null ? data.total_entries : (data.entries || []).length;
863
+ meta.appendChild(el('span', null, entryCount + ' エントリ'));
864
+ if (data.total_size != null) meta.appendChild(el('span', null, '展開後 ' + formatBytes(data.total_size)));
865
+ if (data.compressed_size != null) meta.appendChild(el('span', null, '圧縮 ' + formatBytes(data.compressed_size)));
866
+ if (data.capped) meta.appendChild(el('span', 'archive-capped', '(一部のみ表示)'));
867
+ view.appendChild(meta);
868
+
869
+ // エントリ一覧 (第一階層)。
870
+ const entries = data.entries || [];
871
+ if (entries.length === 0) {
872
+ view.appendChild(el('div', 'archive-empty', '(エントリがありません)'));
873
+ return;
874
+ }
875
+ const ul = el('ul', 'archive-list');
876
+ for (const ent of entries) {
877
+ const li = el('li');
878
+ li.appendChild(el('span', 'a-icon', ent.dir ? '📁' : fileIcon(ent.name)));
879
+ li.appendChild(el('span', 'a-name', ent.name + (ent.dir ? '/' : '')));
880
+ // ディレクトリは配下件数、ファイルはサイズを右側に添える。
881
+ let sub = '';
882
+ if (ent.dir) {
883
+ if (ent.child_count != null) sub = ent.child_count + ' 項目';
884
+ } else if (ent.size != null) {
885
+ sub = formatBytes(ent.size);
886
+ }
887
+ if (sub) li.appendChild(el('span', 'a-sub', sub));
888
+ ul.appendChild(li);
889
+ }
890
+ view.appendChild(ul);
891
+ }
892
+
893
+ function hideArchiveView() {
894
+ const app = document.getElementById('app');
895
+ if (!app.classList.contains('show-archive')) return;
896
+ app.classList.remove('show-archive');
897
+ document.getElementById('archive-view').innerHTML = '';
898
+ }
899
+
680
900
  async function openFile(path, li) {
681
901
  if (!editor) return;
682
902
  // 既に開いているファイルなら、そのタブへ切り替えるだけ (再フェッチしない)。
@@ -686,6 +906,24 @@ async function openFile(path, li) {
686
906
  const data = await r.json();
687
907
  if (!r.ok) { alert(data.error || 'open failed'); return; }
688
908
 
909
+ // 画像 / PDF / 動画 / 音声はメディアタブとして開く (Monaco モデルは持たない)。
910
+ if (data.media) {
911
+ const tab = { path, model: null, viewState: null, dirty: false,
912
+ binary: false, media: data.media, archive: false };
913
+ openTabs.push(tab);
914
+ activateEditorTab(path, li);
915
+ return;
916
+ }
917
+
918
+ // zip / tar 等のアーカイブは第一階層一覧タブとして開く (Monaco モデルは持たない)。
919
+ if (data.archive) {
920
+ const tab = { path, model: null, viewState: null, dirty: false,
921
+ binary: false, media: null, archive: true };
922
+ openTabs.push(tab);
923
+ activateEditorTab(path, li);
924
+ return;
925
+ }
926
+
689
927
  // 新しいモデルを作ってタブに紐づける (以後このモデルが内容・undo 履歴を保持する)。
690
928
  const binary = !!data.binary;
691
929
  const content = binary
@@ -693,11 +931,45 @@ async function openFile(path, li) {
693
931
  : (data.content || '');
694
932
  const lang = binary ? 'plaintext' : langOf(path);
695
933
  const model = monaco.editor.createModel(content, lang);
696
- const tab = { path, model, viewState: null, dirty: false, binary };
934
+ // 元ファイルの改行コードをモデルへ反映する (Monaco は既定で LF に正規化してしまうため、
935
+ // CRLF のファイルを開いて保存すると LF に化ける。開いた時点の実際の改行を尊重する)。
936
+ if (!binary) {
937
+ const crlf = /\r\n/.test(content);
938
+ model.setEOL(crlf ? monaco.editor.EndOfLineSequence.CRLF
939
+ : monaco.editor.EndOfLineSequence.LF);
940
+ }
941
+ const tab = { path, model, viewState: null, dirty: false, binary, media: null, archive: false };
697
942
  openTabs.push(tab);
698
943
  activateEditorTab(path, li);
699
944
  }
700
945
 
946
+ // ステータスバーの改行コード表示を更新する。テキストを編集できるタブ (通常タブ) の
947
+ // ときだけ CRLF/LF を表示し、それ以外 (未選択・メディア・アーカイブ・バイナリ) では隠す。
948
+ function updateEolStatus() {
949
+ const btn = document.getElementById('statusbar-eol');
950
+ const nameEl = document.getElementById('statusbar-eol-name');
951
+ if (!btn || !nameEl) return;
952
+ const tab = currentPath ? findTab(currentPath) : null;
953
+ const editable = tab && tab.model && !tab.media && !tab.archive && !tab.binary;
954
+ if (!editable) { btn.hidden = true; return; }
955
+ btn.hidden = false;
956
+ nameEl.textContent = tab.model.getEOL() === '\r\n' ? 'CRLF' : 'LF';
957
+ }
958
+
959
+ // ステータスバーの改行コードをクリックしたときに CRLF ⇄ LF を切り替える。
960
+ // 切り替えは内容の編集にあたるので dirty 扱いにし、自動保存の有無はエディタ設定に従う
961
+ // (setDirty → scheduleAutoSave。自動保存が OFF なら手動 Ctrl+S まで保存されない)。
962
+ function toggleEol() {
963
+ const tab = currentPath ? findTab(currentPath) : null;
964
+ if (!tab || !tab.model || tab.media || tab.archive || tab.binary) return;
965
+ const toCrlf = tab.model.getEOL() !== '\r\n';
966
+ tab.model.setEOL(toCrlf ? monaco.editor.EndOfLineSequence.CRLF
967
+ : monaco.editor.EndOfLineSequence.LF);
968
+ updateEolStatus();
969
+ setDirty(true);
970
+ scheduleAutoSave();
971
+ }
972
+
701
973
  // 指定パスのエディタタブをアクティブにする。Monaco のモデルを差し替え、ビュー状態を復元する。
702
974
  // (ターミナルタブ用の activateTab とは別関数なので名前を分けている)
703
975
  function activateEditorTab(path, li) {
@@ -717,15 +989,34 @@ function activateEditorTab(path, li) {
717
989
  setPathLabel(path);
718
990
 
719
991
  document.getElementById('app').classList.remove('no-tabs');
720
- suppressChangeEvent = true;
721
- editor.setModel(tab.model);
722
- suppressChangeEvent = false;
723
- if (tab.viewState) editor.restoreViewState(tab.viewState);
724
- editor.updateOptions({ readOnly: tab.binary });
725
- editor.focus();
992
+ if (tab.media) {
993
+ // メディアタブ: Monaco はモデルを外して隠し、メディアビューアを表示する。
994
+ hideArchiveView();
995
+ suppressChangeEvent = true;
996
+ editor.setModel(null);
997
+ suppressChangeEvent = false;
998
+ showMediaView(tab);
999
+ } else if (tab.archive) {
1000
+ // アーカイブタブ: Monaco を隠し、第一階層一覧を表示する。
1001
+ hideMediaView();
1002
+ suppressChangeEvent = true;
1003
+ editor.setModel(null);
1004
+ suppressChangeEvent = false;
1005
+ showArchiveView(tab);
1006
+ } else {
1007
+ hideMediaView();
1008
+ hideArchiveView();
1009
+ suppressChangeEvent = true;
1010
+ editor.setModel(tab.model);
1011
+ suppressChangeEvent = false;
1012
+ if (tab.viewState) editor.restoreViewState(tab.viewState);
1013
+ editor.updateOptions({ readOnly: tab.binary });
1014
+ editor.focus();
1015
+ }
726
1016
 
727
1017
  isDirty = tab.dirty;
728
1018
  setSaveStatus('');
1019
+ updateEolStatus();
729
1020
  renderEditorTabs();
730
1021
 
731
1022
  // ファイル一覧側のハイライトを同期する (li が渡されなければパスから探す)。
@@ -793,6 +1084,9 @@ async function saveCurrent(opts) {
793
1084
  // ファイル未選択で手動保存 (Ctrl+S / メニュー) されたときは、保存先を尋ねる
794
1085
  // ダイアログを開く (自動保存はそもそも未選択時は動かないので auto では出さない)。
795
1086
  if (!currentPath) { if (!auto) openSaveAsDialog(); return; }
1087
+ // メディア/アーカイブタブは編集対象ではないので保存しない。
1088
+ const cur = findTab(currentPath);
1089
+ if (cur && (cur.media || cur.archive)) return;
796
1090
  // バイナリ表示中 (readOnly) は保存しない (プレースホルダで上書きしないため)。
797
1091
  if (editor.getRawOptions && editor.getRawOptions().readOnly) return;
798
1092
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
@@ -1539,7 +1833,9 @@ function addCompactSummary(text) {
1539
1833
 
1540
1834
  // 最終結果に伴うエディタ反映とファイル一覧更新 (描画とは分離した副作用処理)。
1541
1835
  function applyResultSideEffects(data) {
1542
- if (data.content != null && editor && currentPath) {
1836
+ const curTab = currentPath ? findTab(currentPath) : null;
1837
+ // メディア/アーカイブタブ表示中は Monaco にモデルが無いので content を流し込まない。
1838
+ if (data.content != null && editor && currentPath && !(curTab && (curTab.media || curTab.archive))) {
1543
1839
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
1544
1840
  setEditorValue(data.content); // AI が編集した最新内容を反映 (既にディスクへ保存済みなので dirty にはしない)
1545
1841
  setDirty(false);
@@ -2172,6 +2468,7 @@ async function gitCheckout(branch, create) {
2172
2468
  }
2173
2469
 
2174
2470
  document.getElementById('statusbar-branch').onclick = () => openBranchDialog();
2471
+ document.getElementById('statusbar-eol').onclick = () => toggleEol();
2175
2472
  document.getElementById('branch-cancel').onclick = closeBranchDialog;
2176
2473
  document.getElementById('branch-close').onclick = closeBranchDialog;
2177
2474
  document.getElementById('branch-create').onclick = () => {
@@ -115,6 +115,10 @@
115
115
  <div id="editor-wrap">
116
116
  <div id="editor-tabs" role="tablist"></div>
117
117
  <div id="editor"></div>
118
+ <!-- 画像 / PDF / 動画 / 音声のプレビュー領域。メディアタブがアクティブなときだけ表示する。 -->
119
+ <div id="media-view"></div>
120
+ <!-- zip / tar 等アーカイブの第一階層 + メタデータ表示領域。 -->
121
+ <div id="archive-view"></div>
118
122
  <div id="editor-empty">ファイルが開かれていません</div>
119
123
  </div>
120
124
  <div id="rr" class="resizer row" title="ドラッグでエディタ/コンソールの高さを変更"></div>
@@ -137,6 +141,11 @@
137
141
  <button id="statusbar-branch" type="button" title="ブランチを切り替え/作成">
138
142
  <span class="statusbar-branch-icon">⎇</span><span id="statusbar-branch-name">—</span>
139
143
  </button>
144
+ <span class="statusbar-spacer"></span>
145
+ <!-- 改行コード表示。テキストファイルを開いているときだけ表示し、クリックで CRLF/LF を切り替える。 -->
146
+ <button id="statusbar-eol" type="button" title="改行コードを切り替え" hidden>
147
+ <span id="statusbar-eol-name">LF</span>
148
+ </button>
140
149
  </div>
141
150
  </div>
142
151
  <div id="ctx-menu"></div>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chocomint
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ひかり (Himeyama)
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rbzip2
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.3'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.3'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: sqlite3
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -148,6 +162,7 @@ files:
148
162
  - lib/chocomint/console/server.rb
149
163
  - lib/chocomint/console/worker.rb
150
164
  - lib/chocomint/console/worker_process.rb
165
+ - lib/chocomint/edit_archive_handlers.rb
151
166
  - lib/chocomint/edit_git_handlers.rb
152
167
  - lib/chocomint/edit_handlers.rb
153
168
  - lib/chocomint/edit_html.rb