chocomint 1.0.2 → 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 +194 -55
- data/lib/chocomint/server.rb +5 -1
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +72 -3
- data/public/edit/edit.js +515 -118
- data/public/edit/index.html +9 -0
- metadata +16 -1
|
@@ -23,76 +23,81 @@ module Chocomint
|
|
|
23
23
|
@path_guard.allowed_roots.first
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# ---- ファイル一覧
|
|
26
|
+
# ---- ファイル一覧 (遅延展開: 1階層ずつ返す) -----------------------------
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# 中身を展開しない VCS メタデータ・依存パッケージ等 (一覧を埋め尽くすため除外する)。
|
|
29
|
+
# ディレクトリ名 (basename) で判定するため、"vendor/bundle" のようなパス階層は含めない。
|
|
30
|
+
EXCLUDED_TREE_DIRS = %w[.git .hg .svn .venv venv node_modules .bundle].freeze
|
|
31
|
+
|
|
32
|
+
# 1ディレクトリあたりの列挙上限。通常は到達しないが、数万エントリを持つ異常な
|
|
33
|
+
# ディレクトリでブラウザを固めないための安全弁。到達時は capped=true を返す。
|
|
34
|
+
MAX_DIR_ENTRIES = 5000
|
|
35
|
+
|
|
36
|
+
# WORKING DIRECTORY を遅延展開するためのエンドポイント。指定ディレクトリの
|
|
37
|
+
# 「直下 1 階層だけ」を列挙して返す。ツリー全体を一括列挙すると巨大ホーム
|
|
38
|
+
# (例: C:\Users\<name>) で応答が返らず表示が壊れるため、階層ごとに取得する。
|
|
39
|
+
#
|
|
40
|
+
# GET /edit/dir → ルート (workspace 直下) を列挙
|
|
41
|
+
# GET /edit/dir?path=src → src/ の直下を列挙
|
|
42
|
+
#
|
|
43
|
+
# path・返す entries[].path はいずれも base_dir 基準の相対パス (relative_root を含む)。
|
|
44
|
+
# /edit/file や /edit/fs/* と同じ表現なので、フロントは data-path をそのまま渡せる。
|
|
45
|
+
def handle_edit_dir(req, res)
|
|
29
46
|
return edit_method_guard(res) unless req.request_method == "GET"
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
rel = req.query["path"].to_s.tr("\\", "/")
|
|
49
|
+
rel = "" if rel == "." || rel == relative_root
|
|
50
|
+
abs = rel.empty? ? edit_root : @path_guard.resolve(rel)
|
|
51
|
+
return edit_json(res, 404, "error" => "no such directory") unless File.directory?(abs)
|
|
52
|
+
|
|
53
|
+
entries, capped = list_dir_entries(abs, rel)
|
|
54
|
+
edit_json(res, 200, "path" => rel, "entries" => entries, "capped" => capped,
|
|
55
|
+
"root" => relative_root, "abs_root" => edit_root.tr("\\", "/"))
|
|
56
|
+
rescue Chocomint::PathAccessError => e
|
|
57
|
+
edit_json(res, 403, "error" => e.message)
|
|
58
|
+
rescue SystemCallError => e
|
|
59
|
+
# 権限不足などで開けないディレクトリ。空扱いにしてツリーを壊さない。
|
|
60
|
+
edit_json(res, 403, "error" => e.message)
|
|
34
61
|
rescue Chocomint::Error => e
|
|
35
62
|
edit_json(res, 500, "error" => e.message)
|
|
36
63
|
end
|
|
37
64
|
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
# abs_dir の直下 1 階層を列挙し、[entries, capped] を返す。
|
|
66
|
+
# entries は { "name", "path", "dir" } の配列 (ディレクトリ→ファイルの順、各名前順)。
|
|
67
|
+
# rel_dir は abs_dir の base_dir 基準の相対パス ("" ならルート = edit_root)。
|
|
68
|
+
def list_dir_entries(abs_dir, rel_dir)
|
|
69
|
+
dirs = []
|
|
70
|
+
files = []
|
|
71
|
+
capped = false
|
|
44
72
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# 含むホームディレクトリ (例: C:\Users\<name>) を起点にすると応答が返らず WORKING
|
|
49
|
-
# DIRECTORY が空のままになる。除外ディレクトリはその配下へ降りる前に枝刈りし、
|
|
50
|
-
# MAX_LISTED_FILES 到達で打ち切ることで巨大ツリーでも実用的な時間で応答する。
|
|
51
|
-
def walk_workspace_tree
|
|
52
|
-
root = edit_root
|
|
53
|
-
return [[], []] unless File.directory?(root)
|
|
73
|
+
# ルート (rel_dir 空) の直下は base_dir 基準にするため relative_root を前置する
|
|
74
|
+
# (本番は "." のためプレフィックスなし、テスト等 edit_root != base_dir では "workspace" 等)。
|
|
75
|
+
prefix = rel_dir.empty? ? (relative_root == "." ? "" : relative_root) : rel_dir
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
empty_dirs = []
|
|
58
|
-
stack = [""] # root からの相対ディレクトリ (root 自身は "")
|
|
77
|
+
Dir.each_child(abs_dir) do |name|
|
|
78
|
+
next if name == "." || name == ".."
|
|
59
79
|
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
rel = prefix.empty? ? name : "#{prefix}/#{name}"
|
|
81
|
+
abs = File.join(abs_dir, name)
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
if File.directory?(abs)
|
|
84
|
+
# 除外ディレクトリは一覧に出さない (中身も見せない)。
|
|
85
|
+
next if EXCLUDED_TREE_DIRS.include?(name)
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
next if name == "." || name == ".."
|
|
70
|
-
|
|
71
|
-
rel = rel_dir.empty? ? name : "#{rel_dir}/#{name}"
|
|
72
|
-
abs = File.join(abs_dir, name)
|
|
73
|
-
|
|
74
|
-
if File.directory?(abs)
|
|
75
|
-
child_count += 1
|
|
76
|
-
# 除外ディレクトリは配下へ降りない (中身も一覧に出さない)。
|
|
77
|
-
next if EXCLUDED_TREE_DIRS.include?(name)
|
|
78
|
-
|
|
79
|
-
stack.push(rel)
|
|
80
|
-
elsif File.file?(abs)
|
|
81
|
-
child_count += 1
|
|
82
|
-
files << rel
|
|
83
|
-
break if files.size >= MAX_LISTED_FILES
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
rescue SystemCallError
|
|
87
|
-
# 権限不足などで開けないディレクトリはスキップする (ホーム配下で発生しうる)。
|
|
88
|
-
next
|
|
87
|
+
dirs << { "name" => name, "path" => rel, "dir" => true }
|
|
88
|
+
elsif File.file?(abs)
|
|
89
|
+
files << { "name" => name, "path" => rel, "dir" => false }
|
|
89
90
|
end
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
if dirs.size + files.size >= MAX_DIR_ENTRIES
|
|
93
|
+
capped = true
|
|
94
|
+
break
|
|
95
|
+
end
|
|
93
96
|
end
|
|
94
97
|
|
|
95
|
-
|
|
98
|
+
dirs.sort_by! { |e| e["name"] }
|
|
99
|
+
files.sort_by! { |e| e["name"] }
|
|
100
|
+
[dirs + files, capped]
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
# ---- ファイル取得 -------------------------------------------------------
|
|
@@ -105,6 +110,18 @@ module Chocomint
|
|
|
105
110
|
|
|
106
111
|
abs = @path_guard.resolve(path)
|
|
107
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 に丸ごと載せるため)。
|
|
108
125
|
if File.size(abs) > @max_file_bytes
|
|
109
126
|
return edit_json(res, 413, "error" => "file too large")
|
|
110
127
|
end
|
|
@@ -122,6 +139,126 @@ module Chocomint
|
|
|
122
139
|
edit_json(res, 500, "error" => e.message)
|
|
123
140
|
end
|
|
124
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
|
+
|
|
125
262
|
# テキストとして扱えないか判定する。NUL バイトを含む、または先頭ブロックが
|
|
126
263
|
# 妥当な UTF-8 でないものはバイナリ (エディタで開けない) とみなす。
|
|
127
264
|
BINARY_SNIFF_BYTES = 8192
|
|
@@ -150,7 +287,9 @@ module Chocomint
|
|
|
150
287
|
|
|
151
288
|
abs = @path_guard.resolve(path)
|
|
152
289
|
FileUtils.mkdir_p(File.dirname(abs))
|
|
153
|
-
|
|
290
|
+
# バイナリモードで書き込む。Windows の既定 (テキストモード) だと \n が \r\n に
|
|
291
|
+
# 変換され、クライアントが選んだ改行コード (CRLF/LF) を無視して常に CRLF になってしまう。
|
|
292
|
+
File.binwrite(abs, content)
|
|
154
293
|
edit_json(res, 200, "path" => path, "bytes" => content.bytesize)
|
|
155
294
|
rescue JSON::ParserError => e
|
|
156
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 情報も受け取る (任意)。
|
|
@@ -61,8 +63,10 @@ module Chocomint
|
|
|
61
63
|
server.mount_proc("/edit") { |req, res| handle_edit(req, res) }
|
|
62
64
|
server.mount_proc("/edit/assets") { |req, res| handle_edit_asset(req, res) }
|
|
63
65
|
server.mount_proc("/edit/static") { |req, res| handle_edit_static(req, res) }
|
|
64
|
-
server.mount_proc("/edit/
|
|
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,11 +102,17 @@ 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
|
-
white-space: nowrap; overflow: hidden;
|
|
112
|
+
min-width: 0; white-space: nowrap; overflow: hidden; }
|
|
113
|
+
/* ファイル名だけを省略 (…) する。twist / icon は縮めない。 */
|
|
114
|
+
#files .row > span:not(.name) { flex: none; }
|
|
115
|
+
#files .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
110
116
|
#files .row:hover { background: #e3f4f6; }
|
|
111
117
|
#files li.file.active > .row { background: #a2d7dd; font-weight: 600; }
|
|
112
118
|
#files li.dir.selected > .row { background: #d3ecee; font-weight: 600; }
|
|
@@ -170,6 +176,12 @@ html, body { margin: 0; height: 100%;
|
|
|
170
176
|
cursor: pointer; border-radius: 4px; }
|
|
171
177
|
#statusbar-branch:hover { background: rgba(255,255,255,0.15); }
|
|
172
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); }
|
|
173
185
|
/* ブランチ切り替えダイアログの一覧。 */
|
|
174
186
|
.branch-list { list-style: none; margin: 0 0 0.7rem; padding: 0; max-height: 12rem;
|
|
175
187
|
overflow: auto; border: 1px solid #eee; border-radius: 6px; }
|
|
@@ -185,6 +197,63 @@ html, body { margin: 0; height: 100%;
|
|
|
185
197
|
#editor { flex: 1; min-height: 0; min-width: 0; }
|
|
186
198
|
/* 開いているタブが無いときは Monaco を隠し、代わりにプレースホルダーを見せる。 */
|
|
187
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; }
|
|
188
257
|
#editor-empty { display: none; flex: 1; align-items: center; justify-content: center;
|
|
189
258
|
color: #999; font-size: 0.9rem; user-select: none; }
|
|
190
259
|
#app.no-tabs #editor-empty { display: flex; }
|