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.
@@ -23,76 +23,81 @@ module Chocomint
23
23
  @path_guard.allowed_roots.first
24
24
  end
25
25
 
26
- # ---- ファイル一覧 -------------------------------------------------------
26
+ # ---- ファイル一覧 (遅延展開: 1階層ずつ返す) -----------------------------
27
27
 
28
- def handle_edit_files(req, res)
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
- files, empty_dirs = walk_workspace_tree
32
- edit_json(res, 200, "files" => files, "dirs" => empty_dirs,
33
- "root" => relative_root, "abs_root" => edit_root.tr("\\", "/"))
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
- # workspace 配下を再帰列挙し、相対パスの配列で返す (ディレクトリは除く)。
39
- MAX_LISTED_FILES = 2000
40
-
41
- # 中身を展開しない VCS メタデータ・依存パッケージ等 (数千件で一覧を埋め尽くすため除外する)
42
- # ディレクトリ名 (basename) で判定するため、"vendor/bundle" のようなパス階層は含めない。
43
- EXCLUDED_TREE_DIRS = %w[.git .hg .svn .venv venv node_modules .bundle].freeze
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
- # workspace ツリーを1度だけ手動再帰し、[ファイル相対パス, 空ディレクトリ相対パス] を返す。
46
- #
47
- # Dir.glob("**/*") はツリー全体を舐めてから除外判定するため、node_modules を大量に
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
- files = []
56
- # 中身を1件も持たないディレクトリ (files からツリー復元できない) を別途フロントに伝える。
57
- empty_dirs = []
58
- stack = [""] # root からの相対ディレクトリ (root 自身は "")
77
+ Dir.each_child(abs_dir) do |name|
78
+ next if name == "." || name == ".."
59
79
 
60
- until stack.empty?
61
- break if files.size >= MAX_LISTED_FILES
80
+ rel = prefix.empty? ? name : "#{prefix}/#{name}"
81
+ abs = File.join(abs_dir, name)
62
82
 
63
- rel_dir = stack.pop
64
- abs_dir = rel_dir.empty? ? root : File.join(root, rel_dir)
83
+ if File.directory?(abs)
84
+ # 除外ディレクトリは一覧に出さない (中身も見せない)
85
+ next if EXCLUDED_TREE_DIRS.include?(name)
65
86
 
66
- child_count = 0
67
- begin
68
- Dir.each_child(abs_dir) do |name|
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
- # 子を1件も持たないディレクトリはツリーに現れないため、明示的に伝える (root は除く)。
92
- empty_dirs << rel_dir if child_count.zero? && !rel_dir.empty?
92
+ if dirs.size + files.size >= MAX_DIR_ENTRIES
93
+ capped = true
94
+ break
95
+ end
93
96
  end
94
97
 
95
- [files.sort, empty_dirs.sort]
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
- File.write(abs, content)
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}")
@@ -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/files") { |req, res| handle_edit_files(req, res) }
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.2"
4
+ VERSION = "1.1.0"
5
5
  end
6
6
 
7
7
  require_relative "chocomint/errors"
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
- #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
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
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; }