openclacky 1.5.21 → 1.5.22

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -8,12 +8,18 @@ module Clacky
8
8
  module Server
9
9
  # Read-mostly git operations scoped to a session's working directory, backing
10
10
  # the official "git" WebUI panel. Commands run with explicit argv (no shell),
11
- # so user-supplied values (paths, messages) cannot inject. Write operations
12
- # are limited to a guarded `commit` and a single-file `restore`;
11
+ # so user-supplied values (paths, messages) cannot inject. The only write
12
+ # operation is a single-file `restore`; committing is left to the agent, and
13
13
  # history-rewriting / remote-mutating commands are never exposed here.
14
14
  module GitPanel
15
15
  module_function
16
16
 
17
+ # Whole-payload diff budget. A branch comparison against a long-lived
18
+ # branch can be 900 files / 200k lines; serialising that and building the
19
+ # DOM for it locks up the browser, so files past this budget are returned
20
+ # without their diff and the UI loads them on demand.
21
+ DEFAULT_MAX_TOTAL_LINES = 12_000
22
+
17
23
  # Run a git subcommand in `dir` with argv-style args (no shell). Returns
18
24
  # [stdout, stderr, success_bool]. Never raises on git failure.
19
25
  #
@@ -36,9 +42,11 @@ module Clacky
36
42
  end
37
43
 
38
44
  # { branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] }
39
- # Parsed from `git status --porcelain=v2 --branch`.
45
+ # Parsed from `git status --porcelain=v2 --branch`. Untracked files are
46
+ # listed individually (`--untracked-files=all`): the default collapses a
47
+ # new directory into one `dir/` entry, which the panel cannot diff.
40
48
  def status(dir)
41
- out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
49
+ out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch", "--untracked-files=all")
42
50
  return { branch: nil, files: [] } unless ok
43
51
 
44
52
  branch = nil
@@ -70,25 +78,265 @@ module Clacky
70
78
  # they diff against /dev/null via --no-index (rendered as pure
71
79
  # additions); the trailing `--` keeps a leading-dash filename from
72
80
  # being read as an option in that mode too.
73
- def diff(dir, file: nil)
81
+ # `context` overrides the default three lines of surrounding code; the
82
+ # changes panel passes a huge value to reveal the whole file behind a
83
+ # collapsed "N unmodified lines" gap.
84
+ def diff(dir, file: nil, context: nil, scope: "worktree", base: nil)
85
+ extra = context ? ["-U#{context.to_i.clamp(0, 1_000_000).to_s}"] : []
86
+ range = diff_range(dir, scope, base)
74
87
  if file && !file.empty?
75
88
  others, _e, _ok = git(dir, "ls-files", "--others", "--exclude-standard", "--", file)
76
89
  if others.strip.empty?
77
- out, _e, _ok = git(dir, "diff", "HEAD", "--", file)
90
+ out, _e, _ok = git(dir, "diff", *range, *extra, "--", file)
91
+ elsif scope.to_s == "worktree" || scope.to_s == "unstaged"
92
+ out, _e, _ok = git(dir, "diff", "--no-index", *extra, "--", "/dev/null", file)
78
93
  else
79
- out, _e, _ok = git(dir, "diff", "--no-index", "--", "/dev/null", file)
94
+ out = ""
80
95
  end
81
96
  out
82
97
  else
83
- git(dir, "diff", "HEAD")[0]
98
+ git(dir, "diff", *range, *extra)[0]
99
+ end
100
+ end
101
+
102
+ # Baseline args for `git diff` in the given scope. `worktree` keeps the
103
+ # historical behaviour (HEAD); `committed` reviews a single commit the
104
+ # user picked (or the whole unpushed range); `branch` compares the merge
105
+ # base of the chosen branch so unrelated commits on it do not leak in.
106
+ def diff_range(dir, scope, base)
107
+ case scope.to_s
108
+ when "unstaged" then []
109
+ when "staged" then ["--cached", "HEAD"]
110
+ when "committed"
111
+ base_ref = base.to_s.strip
112
+ if base_ref.empty?
113
+ upstream = default_base(dir)
114
+ ref?(dir, upstream) ? ["#{upstream}...HEAD"] : []
115
+ else
116
+ commit_range(dir, base_ref)
117
+ end
118
+ when "branch"
119
+ base_ref = base.to_s.strip
120
+ base_ref = default_base(dir) if base_ref.empty?
121
+ ref?(dir, base_ref) ? ["#{base_ref}...HEAD"] : []
122
+ else ["HEAD"]
123
+ end
124
+ end
125
+
126
+ # A commit picked in the "committed" menu is reviewed on its own: the
127
+ # panel answers "what did this commit change", not "what came after it".
128
+ # Root commits have no parent, so they diff against the empty tree.
129
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
130
+
131
+ def commit_range(dir, sha)
132
+ return [] unless ref?(dir, sha)
133
+
134
+ parent = "#{sha}^"
135
+ ref?(dir, parent) ? ["#{parent}..#{sha}"] : [EMPTY_TREE, sha]
136
+ end
137
+
138
+ # Review scopes, mirroring the selector above the changes panel:
139
+ # worktree - everything not committed yet (baseline HEAD)
140
+ # unstaged - index vs working tree
141
+ # staged - HEAD vs index
142
+ # committed - the branch's commits on top of its base (upstream)
143
+ # branch - the same comparison against a branch the user picked
144
+ # lastTurn - what the previous agent turn touched (served from the
145
+ # Time Machine snapshots by `last_turn_review`, not git)
146
+ SCOPES = %w[worktree unstaged staged committed branch lastTurn].freeze
147
+
148
+ # One payload for the whole changes panel: status order, per-file unified
149
+ # diff, and per-file added/removed line counts. Tracked changes come from
150
+ # a single diff split on its `diff --git` headers so N files cost one
151
+ # subprocess; untracked files have no baseline and are diffed against
152
+ # /dev/null individually (they are usually few).
153
+ #
154
+ # `max_lines` caps each file's diff so a minified bundle cannot stall the
155
+ # browser; `truncated` tells the UI to say so instead of rendering half a
156
+ # hunk silently.
157
+ def review(dir, max_lines: 4000, max_total_lines: DEFAULT_MAX_TOTAL_LINES, scope: nil, base: nil)
158
+ return { repo: false } unless repo?(dir)
159
+
160
+ scope = SCOPES.include?(scope.to_s) ? scope.to_s : "worktree"
161
+ st = status(dir)
162
+ payload = { repo: true, branch: st[:branch], ahead: st[:ahead],
163
+ behind: st[:behind], scope: scope }
164
+
165
+ case scope
166
+ when "unstaged"
167
+ payload.merge!(unstaged_review(dir, st, max_lines: max_lines))
168
+ when "staged"
169
+ payload.merge!(range_review(dir, ["--cached", "HEAD"], max_lines: max_lines,
170
+ max_total_lines: max_total_lines))
171
+ when "committed"
172
+ base_ref = base.to_s.strip
173
+ if base_ref.empty?
174
+ upstream = default_base(dir)
175
+ if upstream.empty? || !ref?(dir, upstream)
176
+ payload.merge!(files: [], add: 0, del: 0, note: "noBase")
177
+ else
178
+ payload[:base] = upstream
179
+ payload.merge!(range_review(dir, ["#{upstream}...HEAD"], max_lines: max_lines,
180
+ max_total_lines: max_total_lines))
181
+ end
182
+ else
183
+ range = commit_range(dir, base_ref)
184
+ if range.empty?
185
+ payload.merge!(files: [], add: 0, del: 0, note: "noBase")
186
+ else
187
+ payload[:commit] = base_ref
188
+ payload.merge!(range_review(dir, range, max_lines: max_lines,
189
+ max_total_lines: max_total_lines))
190
+ end
191
+ end
192
+ when "branch"
193
+ base_ref = base.to_s.strip
194
+ base_ref = default_base(dir) if base_ref.empty?
195
+ if base_ref.empty? || !ref?(dir, base_ref)
196
+ payload.merge!(files: [], add: 0, del: 0, note: "noBase")
197
+ else
198
+ payload[:base] = base_ref
199
+ payload.merge!(range_review(dir, ["#{base_ref}...HEAD"], max_lines: max_lines,
200
+ max_total_lines: max_total_lines))
201
+ end
202
+ else
203
+ payload.merge!(worktree_review(dir, st, max_lines: max_lines,
204
+ max_total_lines: max_total_lines))
84
205
  end
85
206
  end
86
207
 
87
- # Recent commits: [{ hash:, short:, author:, date:, subject: }].
88
- def log(dir, limit: 50)
208
+ def worktree_review(dir, st, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
209
+ chunks = split_diff(git(dir, "diff", "HEAD")[0])
210
+ entries_review(st[:files], max_lines: max_lines, max_total_lines: max_total_lines) do |f|
211
+ f[:untracked] ? diff(dir, file: f[:path]) : (chunks[f[:path]] || diff(dir, file: f[:path]))
212
+ end
213
+ end
214
+
215
+ def unstaged_review(dir, st, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
216
+ chunks = split_diff(git(dir, "diff")[0])
217
+ subset = st[:files].select { |f| f[:untracked] || f[:y].to_s != "." }
218
+ entries_review(subset, max_lines: max_lines, max_total_lines: max_total_lines) do |f|
219
+ f[:untracked] ? diff(dir, file: f[:path]) : chunks[f[:path]].to_s
220
+ end
221
+ end
222
+
223
+ def range_review(dir, range_args, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
224
+ chunks = split_diff(git(dir, "diff", *range_args)[0])
225
+ files = chunks.map do |path, text|
226
+ build_file(path, text, max_lines: max_lines,
227
+ x: diff_letter(text), y: ".")
228
+ end
229
+ totals(budget_files(files, max_total_lines))
230
+ end
231
+
232
+ def entries_review(entries, max_lines:, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
233
+ files = entries.map do |f|
234
+ text = yield(f).to_s
235
+ build_file(f[:path], text, max_lines: max_lines,
236
+ x: f[:x], y: f[:y], untracked: f[:untracked])
237
+ end
238
+ totals(budget_files(files, max_total_lines))
239
+ end
240
+
241
+ # A branch comparison can span hundreds of files and hundreds of
242
+ # thousands of lines. Shipping every diff as JSON freezes the browser
243
+ # while it builds that much DOM, so files past the budget lose their
244
+ # `diff` payload and are flagged `lazy` — the UI fetches them one at a
245
+ # time when the user actually opens one. Counts stay intact so the tree
246
+ # still shows accurate +/- badges.
247
+ def budget_files(files, max_total_lines)
248
+ remaining = max_total_lines.to_i
249
+ files.each do |f|
250
+ if remaining <= 0
251
+ f[:lazy] = true
252
+ f[:diff] = ""
253
+ else
254
+ remaining -= f[:diff].lines.size
255
+ end
256
+ end
257
+ files
258
+ end
259
+
260
+ def build_file(path, text, max_lines:, x: "M", y: ".", untracked: false, binary: nil)
261
+ text, truncated = trim_diff(text.to_s, max_lines)
262
+ add, del = count_changes(text)
263
+ { path: path, x: x, y: y, untracked: untracked,
264
+ add: add, del: del, truncated: truncated,
265
+ binary: binary.nil? ? text.match?(/^Binary files .* differ$/) : binary,
266
+ diff: text }
267
+ end
268
+
269
+ def totals(files)
270
+ { files: files,
271
+ add: files.sum { |f| f[:add] },
272
+ del: files.sum { |f| f[:del] } }
273
+ end
274
+
275
+ # A file added or removed between the two sides of a diff has no content
276
+ # on one of them; the UI uses the letter for its A/D/M tag.
277
+ def diff_letter(text)
278
+ return "A" if text.match?(/^new file mode /)
279
+ return "D" if text.match?(/^deleted file mode /)
280
+ "M"
281
+ end
282
+
283
+ # Upstream of the current branch is the natural base for "committed":
284
+ # it is exactly the commits that have not been pushed yet. Falling back
285
+ # to origin/HEAD and then a local main/master covers fresh clones and
286
+ # branches without an upstream.
287
+ def default_base(dir)
288
+ out, _err, ok = git(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
289
+ return out.strip if ok && !out.strip.empty?
290
+
291
+ out, _err, ok = git(dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
292
+ return out.strip if ok && !out.strip.empty?
293
+
294
+ %w[origin/main origin/master main master].find { |name| ref?(dir, name) }.to_s
295
+ end
296
+
297
+ def ref?(dir, name)
298
+ return false if name.to_s.empty? || name.start_with?("-")
299
+
300
+ _out, _err, ok = git(dir, "rev-parse", "--verify", "--quiet", "#{name}^{commit}")
301
+ ok
302
+ end
303
+
304
+ # Scope "lastTurn": what the previous agent turn changed. Git has no
305
+ # notion of a turn, so this comes from Time Machine snapshots — each task
306
+ # keeps a BEFORE/AFTER tree of the files it touched.
307
+ def last_turn_review(agent, max_lines: 4000, max_total_lines: DEFAULT_MAX_TOTAL_LINES)
308
+ payload = { repo: true, scope: "lastTurn", files: [], add: 0, del: 0, note: "noTurn" }
309
+ return payload unless agent.respond_to?(:get_task_history)
310
+
311
+ history = agent.get_task_history(limit: 20)
312
+ current = history.find { |task| task[:status] == :current }
313
+ task_id = current ? current[:parent_task_id].to_i : 0
314
+ return payload if task_id <= 0
315
+
316
+ files = agent.task_diff_files(task_id).map do |entry|
317
+ detail = agent.task_file_diff(task_id, entry[:path]) || {}
318
+ build_file(entry[:path], detail[:patch].to_s, max_lines: max_lines,
319
+ x: { "added" => "A", "deleted" => "D" }.fetch(entry[:status], "M"),
320
+ y: ".", binary: detail[:binary] ? true : nil)
321
+ end
322
+ totals(budget_files(files, max_total_lines)).merge(repo: true, scope: "lastTurn", task_id: task_id)
323
+ end
324
+
325
+ # Recent commits: [{ hash:, short:, author:, date:, subject: }]. With
326
+ # `unpushed: true` the log is limited to the commits the default base
327
+ # does not contain yet — the same range the "committed" scope diffs, so
328
+ # the menu never offers a commit that is already on the remote.
329
+ def log(dir, limit: 50, unpushed: false)
89
330
  limit = limit.to_i.clamp(1, 200)
90
331
  fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
91
- out, _err, ok = git(dir, "log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}")
332
+ args = ["log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}"]
333
+ if unpushed
334
+ base = default_base(dir)
335
+ return [] unless ref?(dir, base)
336
+
337
+ args << "#{base}..HEAD"
338
+ end
339
+ out, _err, ok = git(dir, *args)
92
340
  return [] unless ok
93
341
 
94
342
  out.each_line.filter_map do |line|
@@ -98,18 +346,66 @@ module Clacky
98
346
  end
99
347
  end
100
348
 
101
- # [{ name:, current: bool }] from `git branch`.
349
+ # [{ name:, current: bool, remote: bool }] from `git branch --all`.
350
+ # Remote HEAD pointers (`origin/HEAD` → `origin/main`) are dropped: they
351
+ # are aliases, not branches the user can compare against.
102
352
  def branches(dir)
103
- out, _err, ok = git(dir, "branch", "--format=%(refname:short)%00%(HEAD)")
353
+ out, _err, ok = git(dir, "branch", "--all",
354
+ "--format=%(refname)%00%(refname:short)%00%(HEAD)")
104
355
  return [] unless ok
105
356
 
106
357
  out.each_line.filter_map do |line|
107
- name, head = line.chomp.split("\x00")
108
- next unless name && !name.empty?
109
- { name: name, current: head == "*" }
358
+ full, name, head = line.chomp.split("\x00")
359
+ next unless full && name && !name.empty?
360
+ next if full.end_with?("/HEAD")
361
+ { name: name, current: head == "*", remote: full.start_with?("refs/remotes/") }
110
362
  end
111
363
  end
112
364
 
365
+ # Current branch name, or nil when HEAD is detached / the dir is not a repo.
366
+ def branch_name(dir)
367
+ out, _err, ok = git(dir, "symbolic-ref", "--short", "-q", "HEAD")
368
+ ok ? out.strip : nil
369
+ end
370
+
371
+ # Whether `name` is a branch (or ref) git would accept. Refusing a leading
372
+ # dash keeps the value from being parsed as an option by the argv call
373
+ # below; the rest of the rules come from git itself so aliases such as
374
+ # `origin/pr-685` stay usable.
375
+ #
376
+ # Returns true/false - never raises.
377
+ def branch_ref?(dir, name)
378
+ ref = name.to_s.strip
379
+ return false if ref.empty? || ref.start_with?("-")
380
+
381
+ _out, _err, ok = git(dir, "check-ref-format", "--branch", ref)
382
+ ok
383
+ end
384
+
385
+ # Switch the work tree to `branch`, optionally creating it (from `from`,
386
+ # or from HEAD when omitted). Uncommitted changes are left to git: it
387
+ # either carries them across or refuses with a message the UI shows as-is.
388
+ def checkout(dir, branch:, create: false, from: nil)
389
+ name = branch.to_s.strip
390
+ return { ok: false, error: "branch is required" } if name.empty?
391
+ return { ok: false, error: "invalid branch name" } unless branch_ref?(dir, name)
392
+
393
+ args = ["checkout"]
394
+ if create
395
+ start = from.to_s.strip
396
+ return { ok: false, error: "invalid base ref" } unless start.empty? || branch_ref?(dir, start)
397
+ args.push("-b", name)
398
+ args.push(start) unless start.empty?
399
+ else
400
+ args.push(name)
401
+ end
402
+
403
+ _out, err, ok = git(dir, *args)
404
+ return { ok: true, branch: name } if ok
405
+
406
+ { ok: false, error: err.strip.empty? ? "git checkout failed" : err.strip }
407
+ end
408
+
113
409
  # Discard uncommitted changes to one file and return it to its HEAD
114
410
  # state (same baseline the diff API uses, so "what you saw is what you
115
411
  # lose"). Files that never existed in HEAD (staged-new or untracked)
@@ -147,28 +443,58 @@ module Clacky
147
443
  end
148
444
  end
149
445
 
150
- # Relative repo paths only: no absolute paths, no traversal segments.
151
- def invalid_path?(path)
152
- path.start_with?("/") || path.split("/").include?("..")
446
+ # Split a multi-file `git diff` into { path => chunk } keyed by the repo
447
+ # path, preferring the `+++ b/` side (the `--- a/` side is
448
+ # `/dev/null` for added files).
449
+ def split_diff(text)
450
+ chunks = {}
451
+ path = nil
452
+ buf = []
453
+ flush = lambda do
454
+ chunks[path] = buf.join if path && !buf.empty?
455
+ end
456
+ text.each_line do |line|
457
+ if line.start_with?("diff --git ")
458
+ flush.call
459
+ buf = [line]
460
+ path = nil
461
+ else
462
+ buf << line
463
+ next if path
464
+ if line.start_with?("--- a/")
465
+ path = line.sub("--- a/", "").chomp
466
+ elsif line.start_with?("+++ b/")
467
+ path = line.sub("+++ b/", "").chomp
468
+ end
469
+ end
470
+ end
471
+ flush.call
472
+ chunks
153
473
  end
154
474
 
155
- # Stage `files` (relative paths) and commit with `message`. Returns
156
- # { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
157
- # argv so paths/message cannot inject; no --no-verify, no amend.
158
- def commit(dir, message:, files:)
159
- msg = message.to_s.strip
160
- paths = Array(files).map(&:to_s).reject(&:empty?)
161
- return { ok: false, error: "commit message is required" } if msg.empty?
162
- return { ok: false, error: "no files selected" } if paths.empty?
163
-
164
- _out, add_err, add_ok = git(dir, "add", "--", *paths)
165
- return { ok: false, error: "git add failed: #{add_err.strip}" } unless add_ok
475
+ def count_changes(text)
476
+ add = 0
477
+ del = 0
478
+ text.each_line do |line|
479
+ next if line.start_with?("+++", "---")
480
+ if line.start_with?("+")
481
+ add += 1
482
+ elsif line.start_with?("-")
483
+ del += 1
484
+ end
485
+ end
486
+ [add, del]
487
+ end
166
488
 
167
- _out, c_err, c_ok = git(dir, "commit", "-m", msg, "--", *paths)
168
- return { ok: false, error: "git commit failed: #{c_err.strip}" } unless c_ok
489
+ def trim_diff(text, max_lines)
490
+ lines = text.lines
491
+ return [text, false] if lines.size <= max_lines
492
+ [lines.first(max_lines).join, true]
493
+ end
169
494
 
170
- head, _err, _ok = git(dir, "rev-parse", "--short", "HEAD")
171
- { ok: true, hash: head.strip }
495
+ # Relative repo paths only: no absolute paths, no traversal segments.
496
+ def invalid_path?(path)
497
+ path.start_with?("/") || path.split("/").include?("..")
172
498
  end
173
499
  end
174
500
  end