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 +4 -4
- data/README.md +27 -1
- data/THIRD_PARTY_LICENSES.md +1 -0
- data/lib/chocomint/edit_archive_handlers.rb +323 -0
- data/lib/chocomint/edit_handlers.rb +135 -1
- data/lib/chocomint/server.rb +4 -0
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +68 -2
- data/public/edit/edit.js +306 -9
- data/public/edit/index.html +9 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 691a0248c4e919afa5640c7398b3f1b06221c397ac2c1e4c73fb56c044174246
|
|
4
|
+
data.tar.gz: 5371ad706155faff73120e8064f6629b7a776099e7c66fc5f07a89bfec932799
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 不要)。
|
data/THIRD_PARTY_LICENSES.md
CHANGED
|
@@ -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
|
-
|
|
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}")
|
data/lib/chocomint/server.rb
CHANGED
|
@@ -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
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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 = () => {
|
data/public/edit/index.html
CHANGED
|
@@ -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
|
|
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
|