petari 0.3.0 → 0.4.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.
- package/README.md +23 -6
- package/dist/cli.js +4 -1
- package/dist/commands/show.js +205 -39
- package/dist/core/diff-html.js +272 -0
- package/dist/core/diff.js +177 -0
- package/dist/core/edit.js +83 -0
- package/dist/core/editor-html.js +301 -0
- package/dist/infra/browser.js +24 -0
- package/dist/infra/diff-server.js +437 -0
- package/package.json +7 -4
- package/vendor/monaco/LICENSE.txt +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/abap-D-t0cyap.js +1 -0
- package/vendor/monaco/vs/apex-CcIm7xu6.js +1 -0
- package/vendor/monaco/vs/assets/css.worker-URu8fCFR.js +97 -0
- package/vendor/monaco/vs/assets/editor.worker-lj3bdIIn.js +30 -0
- package/vendor/monaco/vs/assets/editorWebWorkerMain-CA_vMoUU.js +30 -0
- package/vendor/monaco/vs/assets/html.worker-D1SL3iM8.js +474 -0
- package/vendor/monaco/vs/assets/json.worker-CoJx_OPf.js +62 -0
- package/vendor/monaco/vs/assets/ts.worker-BWKtMYOk.js +67722 -0
- package/vendor/monaco/vs/azcli-BA0tQDCg.js +1 -0
- package/vendor/monaco/vs/basic-languages/monaco.contribution.js +1 -0
- package/vendor/monaco/vs/bat-C397hTD6.js +1 -0
- package/vendor/monaco/vs/bicep-DF5aW17k.js +2 -0
- package/vendor/monaco/vs/cameligo-plsz8qhj.js +1 -0
- package/vendor/monaco/vs/clojure-Y2auQMzK.js +1 -0
- package/vendor/monaco/vs/coffee-Bu45yuWE.js +1 -0
- package/vendor/monaco/vs/cpp-CkKPQIni.js +1 -0
- package/vendor/monaco/vs/csharp-CX28MZyh.js +1 -0
- package/vendor/monaco/vs/csp-D8uWnyxW.js +1 -0
- package/vendor/monaco/vs/css-CaeNmE3S.js +3 -0
- package/vendor/monaco/vs/css.worker-CyhWkhHo.js +1 -0
- package/vendor/monaco/vs/cssMode-CV6Ay48H.js +1 -0
- package/vendor/monaco/vs/cypher-DVThT8BS.js +1 -0
- package/vendor/monaco/vs/dart-CmGfCvrO.js +1 -0
- package/vendor/monaco/vs/dockerfile-CZqqYdch.js +1 -0
- package/vendor/monaco/vs/ecl-30fUercY.js +1 -0
- package/vendor/monaco/vs/editor/editor.main.css +1 -0
- package/vendor/monaco/vs/editor/editor.main.js +1 -0
- package/vendor/monaco/vs/editor/editor.worker.js +1 -0
- package/vendor/monaco/vs/editor-KLE6jdfb.js +831 -0
- package/vendor/monaco/vs/editor.js +1 -0
- package/vendor/monaco/vs/editorWorkerHost-fVE1cjcC.js +35 -0
- package/vendor/monaco/vs/elixir-xjPaIfzF.js +1 -0
- package/vendor/monaco/vs/flow9-DqtmStfK.js +1 -0
- package/vendor/monaco/vs/freemarker2-FWreY7v7.js +3 -0
- package/vendor/monaco/vs/fsharp-BOMdg4U1.js +1 -0
- package/vendor/monaco/vs/go-D_hbi-Jt.js +1 -0
- package/vendor/monaco/vs/graphql-CKUU4kLG.js +1 -0
- package/vendor/monaco/vs/handlebars-CZi4pHCH.js +1 -0
- package/vendor/monaco/vs/hcl-DTaboeZW.js +1 -0
- package/vendor/monaco/vs/html-j_7ZTNRU.js +1 -0
- package/vendor/monaco/vs/html.worker-CA3iAimZ.js +1 -0
- package/vendor/monaco/vs/htmlMode-27by_KM4.js +1 -0
- package/vendor/monaco/vs/index-049xrXrp.js +1 -0
- package/vendor/monaco/vs/index-CBVt3dzv.js +5 -0
- package/vendor/monaco/vs/index.js +1 -0
- package/vendor/monaco/vs/ini-CsNwO04R.js +1 -0
- package/vendor/monaco/vs/initialize-DL0l1TGY.js +1 -0
- package/vendor/monaco/vs/java-CI4ZMsH9.js +1 -0
- package/vendor/monaco/vs/javascript-DTJl4Jn1.js +1 -0
- package/vendor/monaco/vs/json.worker-BizpAl9O.js +1 -0
- package/vendor/monaco/vs/jsonMode-DLUiMUrI.js +1 -0
- package/vendor/monaco/vs/julia-BwzEvaQw.js +1 -0
- package/vendor/monaco/vs/kotlin-IUYPiTV8.js +1 -0
- package/vendor/monaco/vs/language/css/css.worker.js +66 -0
- package/vendor/monaco/vs/language/css/monaco.contribution.js +1 -0
- package/vendor/monaco/vs/language/html/html.worker.js +443 -0
- package/vendor/monaco/vs/language/html/monaco.contribution.js +1 -0
- package/vendor/monaco/vs/language/json/json.worker.js +17 -0
- package/vendor/monaco/vs/language/json/monaco.contribution.js +1 -0
- package/vendor/monaco/vs/language/typescript/monaco.contribution.js +1 -0
- package/vendor/monaco/vs/language/typescript/ts.worker.js +67693 -0
- package/vendor/monaco/vs/less-C0eDYdqa.js +2 -0
- package/vendor/monaco/vs/lexon-iON-Kj97.js +1 -0
- package/vendor/monaco/vs/liquid-CLJYelW6.js +1 -0
- package/vendor/monaco/vs/loader.js +1368 -0
- package/vendor/monaco/vs/lspLanguageFeatures-BIkJOWLw.js +2 -0
- package/vendor/monaco/vs/lua-DtygF91M.js +1 -0
- package/vendor/monaco/vs/m3-CsR4AuFi.js +1 -0
- package/vendor/monaco/vs/main-BEx-Fmlo.js +15 -0
- package/vendor/monaco/vs/main-DsK8pnKg.js +3 -0
- package/vendor/monaco/vs/markdown-C_rD0bIw.js +1 -0
- package/vendor/monaco/vs/mdx-CpzZIPHF.js +1 -0
- package/vendor/monaco/vs/mips-CiYP61RB.js +1 -0
- package/vendor/monaco/vs/monaco.contribution-9cKT3C7t.js +1 -0
- package/vendor/monaco/vs/monaco.contribution-BE88ZNGY.js +1 -0
- package/vendor/monaco/vs/monaco.contribution-BPhsneLd.js +1 -0
- package/vendor/monaco/vs/monaco.contribution-BgRy6xDf.js +1 -0
- package/vendor/monaco/vs/msdax-C38-sJlp.js +1 -0
- package/vendor/monaco/vs/mysql-CdtbpvbG.js +1 -0
- package/vendor/monaco/vs/nls/lang/cs.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/cs.js +9 -0
- package/vendor/monaco/vs/nls/lang/de.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/de.js +9 -0
- package/vendor/monaco/vs/nls/lang/es.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/es.js +9 -0
- package/vendor/monaco/vs/nls/lang/fr.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/fr.js +9 -0
- package/vendor/monaco/vs/nls/lang/it.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/it.js +9 -0
- package/vendor/monaco/vs/nls/lang/ja.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/ja.js +9 -0
- package/vendor/monaco/vs/nls/lang/ko.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/ko.js +9 -0
- package/vendor/monaco/vs/nls/lang/pl.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/pl.js +9 -0
- package/vendor/monaco/vs/nls/lang/pt-br.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/pt-br.js +9 -0
- package/vendor/monaco/vs/nls/lang/ru.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/ru.js +9 -0
- package/vendor/monaco/vs/nls/lang/tr.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/tr.js +9 -0
- package/vendor/monaco/vs/nls/lang/zh-cn.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/zh-cn.js +9 -0
- package/vendor/monaco/vs/nls/lang/zh-tw.d.ts +1 -0
- package/vendor/monaco/vs/nls/lang/zh-tw.js +9 -0
- package/vendor/monaco/vs/nls.messages-loader.js +1 -0
- package/vendor/monaco/vs/objective-c-CntZFaHX.js +1 -0
- package/vendor/monaco/vs/pascal-r6kuqfl_.js +1 -0
- package/vendor/monaco/vs/pascaligo-BiXoTmXh.js +1 -0
- package/vendor/monaco/vs/perl-DABw_TcH.js +1 -0
- package/vendor/monaco/vs/pgsql-me_jFXeX.js +1 -0
- package/vendor/monaco/vs/php-D_kh-9LK.js +1 -0
- package/vendor/monaco/vs/pla-VfZjczW0.js +1 -0
- package/vendor/monaco/vs/postiats-BBSzz8Pk.js +1 -0
- package/vendor/monaco/vs/powerquery-Dt-g_2cc.js +1 -0
- package/vendor/monaco/vs/powershell-B-7ap1zc.js +1 -0
- package/vendor/monaco/vs/protobuf-BmtuEB1A.js +2 -0
- package/vendor/monaco/vs/pug-BRpRNeEb.js +1 -0
- package/vendor/monaco/vs/python-CqWUUgfu.js +1 -0
- package/vendor/monaco/vs/qsharp-BzsFaUU9.js +1 -0
- package/vendor/monaco/vs/r-f8dDdrp4.js +1 -0
- package/vendor/monaco/vs/razor-hok5y9Km.js +1 -0
- package/vendor/monaco/vs/redis-fvZQY4PI.js +1 -0
- package/vendor/monaco/vs/redshift-45Et0LQi.js +1 -0
- package/vendor/monaco/vs/restructuredtext-C7UUFKFD.js +1 -0
- package/vendor/monaco/vs/ruby-CZO8zYTz.js +1 -0
- package/vendor/monaco/vs/rust-Bfetafyc.js +1 -0
- package/vendor/monaco/vs/sb-3GYllVck.js +1 -0
- package/vendor/monaco/vs/scala-foMgrKo1.js +1 -0
- package/vendor/monaco/vs/scheme-CHdMtr7p.js +1 -0
- package/vendor/monaco/vs/scss-C1cmLt9V.js +3 -0
- package/vendor/monaco/vs/shell-ClXCKCEW.js +1 -0
- package/vendor/monaco/vs/solidity-MZ6ExpPy.js +1 -0
- package/vendor/monaco/vs/sophia-DWkuSsPQ.js +1 -0
- package/vendor/monaco/vs/sparql-AUGFYSyk.js +1 -0
- package/vendor/monaco/vs/sql-32GpJSV2.js +1 -0
- package/vendor/monaco/vs/st-CuDFIVZ_.js +1 -0
- package/vendor/monaco/vs/swift-t-PfMj7W.js +1 -0
- package/vendor/monaco/vs/systemverilog-Ch4vA8Yt.js +1 -0
- package/vendor/monaco/vs/tcl-D74tq1nH.js +1 -0
- package/vendor/monaco/vs/toggleHighContrast-qGX7E9o7.js +61 -0
- package/vendor/monaco/vs/ts.worker-2QLmBukE.js +1 -0
- package/vendor/monaco/vs/tsMode-B0S_kp2q.js +11 -0
- package/vendor/monaco/vs/twig-C6taOxMV.js +1 -0
- package/vendor/monaco/vs/typescript-_2t_511t.js +1 -0
- package/vendor/monaco/vs/typespec-8OfoLt6R.js +1 -0
- package/vendor/monaco/vs/vb-Dyb2648j.js +1 -0
- package/vendor/monaco/vs/wgsl-BhLXMOR0.js +298 -0
- package/vendor/monaco/vs/workers-BBttULjf.js +1 -0
- package/vendor/monaco/vs/xml-cZjNDqDT.js +1 -0
- package/vendor/monaco/vs/yaml-A1fOIdH6.js +1 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
|
|
|
54
54
|
|---|---|
|
|
55
55
|
| `petari [path]` | changes.md を適用。省略時は Downloads とプロジェクト直下から自動検出 (直近 30 分)。`--clip` でクリップボードから |
|
|
56
56
|
| `petari undo [ID]` | 履歴を巻き戻す (手修正がある場合は警告) |
|
|
57
|
-
| `petari show
|
|
57
|
+
| `petari show [ID]` | 履歴の差分を表示 (VS Code、なければブラウザにフォールバック)。`--edit` で現在のファイルを差分を見ながら手修正、`--mine` で適用後の手修正分、`--changes` で変更概要、`--browser` でブラウザ強制 |
|
|
58
58
|
| `petari list` | 履歴の一覧と合計サイズ |
|
|
59
59
|
| `petari protocol` | AI への規約文を標準出力に出す (slnmix / repomix 連携用) |
|
|
60
60
|
| `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意) |
|
|
@@ -72,8 +72,10 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
|
|
|
72
72
|
- **エンコーディング保全**: Shift_JIS / UTF-8 (BOM 有無)・CRLF / LF・末尾改行を完全維持。
|
|
73
73
|
変更していない行は元のバイト列をそのまま書き戻す (レガシー VB.NET 資産でも安全)
|
|
74
74
|
- **失敗レポート**: そのまま AI チャットに貼り返せる形式で出力。再依頼が 1 コピペで済む
|
|
75
|
-
- **実行時依存ゼロ**:
|
|
76
|
-
(Shift_JIS 変換テーブルも Node 組み込み ICU から自動生成して同梱)
|
|
75
|
+
- **実行時依存ゼロ**: サプライチェーン対策として npm の実行時依存パッケージを持たない
|
|
76
|
+
(Shift_JIS 変換テーブルも Node 組み込み ICU から自動生成して同梱)。
|
|
77
|
+
唯一の同梱サードパーティはブラウザ編集 UI の Monaco Editor (Microsoft 製・MIT) で、
|
|
78
|
+
固定バージョンをビルド時にパッケージへ同梱し、実行時のダウンロードは行わない
|
|
77
79
|
|
|
78
80
|
## セキュリティ設計
|
|
79
81
|
|
|
@@ -81,14 +83,25 @@ petari の更新で規約文が変わった場合は、`petari init` を再実
|
|
|
81
83
|
|
|
82
84
|
### 通信・依存関係
|
|
83
85
|
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
+
- **外部へのネットワーク通信は一切行いません**。AI との通信機能はなく、入力はローカルファイル
|
|
87
|
+
またはクリップボードのみ。テレメトリ・自動更新もありません。
|
|
88
|
+
例外は `show --edit` のブラウザ編集時のみで、**127.0.0.1 (ループバック) の一時ポート**で
|
|
89
|
+
待ち受けます — 128bit 乱数トークン付き URL・Host/Origin 検証・無操作 30 分で自動終了。
|
|
90
|
+
実行できるスクリプトは CSP で「**同梱した Monaco Editor** (script-src 'self') と
|
|
91
|
+
**自前ブートストラップ** (応答ごとの nonce)」に限定し、閲覧レポート等は JS 実行不可。
|
|
92
|
+
Monaco 資産の配信は起動時に列挙した同梱ファイルの許可リストへの完全一致のみです。
|
|
93
|
+
外部への送信・CDN・外部リソースの読み込みは一切発生しません
|
|
86
94
|
- **実行時依存パッケージはゼロ**です。npm のインストールスクリプト (postinstall 等) も
|
|
87
95
|
使用しません。Shift_JIS 変換テーブルは Node.js 組み込みの ICU から生成しリポジトリに
|
|
88
96
|
同梱しています (外部データの取り込みなし)
|
|
97
|
+
- 同梱サードパーティは **monaco-editor (Microsoft, MIT) の 1 件のみ**。開発時に固定
|
|
98
|
+
バージョンを取得し (7 日未満の新規公開バージョンは取得しないポリシー)、ビルド時に
|
|
99
|
+
パッケージへ同梱します。ライセンス・ThirdPartyNotices も同梱し、実行時に外部から
|
|
100
|
+
コードを取得することはありません
|
|
89
101
|
- 子プロセスの起動はすべて `execFile`/`spawn` の配列引数で行い、シェルを経由しません
|
|
90
102
|
(コマンドインジェクション不成立)。起動するのは `git` / `reg` / `powershell` /
|
|
91
|
-
`pbcopy`・`pbpaste` / VS Code (`code`)
|
|
103
|
+
`pbcopy`・`pbpaste` / VS Code (`code`) / 既定ブラウザ起動 (`open` / `xdg-open` /
|
|
104
|
+
PowerShell `Start-Process`) のみです
|
|
92
105
|
|
|
93
106
|
### 入力の取り扱い (信頼境界)
|
|
94
107
|
|
|
@@ -105,6 +118,10 @@ changes.md (AI 出力)・クリップボード・リポジトリに同梱され
|
|
|
105
118
|
表示します (表示偽装対策)
|
|
106
119
|
- 差分ビューアのコマンド (`vscodeCommand` 設定) は PATH 上のコマンド名か絶対パスのみ許可し、
|
|
107
120
|
リポジトリ内スクリプトを指せる相対パスと UNC パスを拒否します
|
|
121
|
+
- ブラウザ編集 (`show --edit`) の保存は、トークン検証に加えて**保存先パスの再検証・
|
|
122
|
+
シンボリックリンク拒否・SHA-256 楽観ロック** (ページを開いた後の外部変更を検出し 409) を
|
|
123
|
+
通します。編集対象はサーバー起動時に確定し、ブラウザ側からパスは指定できません。
|
|
124
|
+
保存も §8 のエンコーディング保全 (無変更行は元バイト維持) を通ります
|
|
108
125
|
|
|
109
126
|
### 適用の安全弁と監査性
|
|
110
127
|
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ const HELP = `petari — paste AI chat patches onto your codebase
|
|
|
13
13
|
Usage:
|
|
14
14
|
petari [path] changes.md を適用 (省略時: Downloads から自動検出)
|
|
15
15
|
petari undo [ID] 履歴を巻き戻す (ID 省略時: 直近)
|
|
16
|
-
petari show
|
|
16
|
+
petari show [ID] 履歴の差分を表示 (VS Code / ブラウザ)。--edit で手修正
|
|
17
17
|
petari list 履歴の一覧を表示
|
|
18
18
|
petari protocol AI への規約文を標準出力に出す
|
|
19
19
|
petari init プロジェクト初回セットアップ
|
|
@@ -23,6 +23,9 @@ Options:
|
|
|
23
23
|
-v, --version バージョンを表示
|
|
24
24
|
`;
|
|
25
25
|
export async function main(argv) {
|
|
26
|
+
// pnpm run は "--" をそのまま渡してくる (npm と異なり消費しない) ため先頭の区切りを除く
|
|
27
|
+
if (argv[0] === "--")
|
|
28
|
+
argv = argv.slice(1);
|
|
26
29
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
27
30
|
process.stdout.write(HELP);
|
|
28
31
|
return 0;
|
package/dist/commands/show.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* petari show
|
|
3
|
-
* - 既定: before vs after
|
|
2
|
+
* petari show [ID] — 履歴の差分表示 (§4.3)。
|
|
3
|
+
* - 既定: before vs after。VS Code の差分ビューで開き、VS Code が見つからない
|
|
4
|
+
* 場合 (ENOENT/EINVAL) は自己完結 HTML レポートを生成してブラウザで開く
|
|
4
5
|
* - --mine: after vs 現在のファイル = 適用後の手修正分だけを可視化
|
|
5
|
-
* - --
|
|
6
|
-
*
|
|
6
|
+
* - --edit: スナップショット vs 現在の実ファイル。VS Code では右ペインが実ファイル
|
|
7
|
+
* (そのまま編集可)。VS Code がなければ 127.0.0.1 限定の一時サーバーでブラウザ編集
|
|
8
|
+
* - --browser: VS Code を試さずブラウザ表示を強制
|
|
9
|
+
* - --no-open: ブラウザを開かずパス/URL の表示のみ (SSH・テスト用)
|
|
10
|
+
* - --changes: 保存済み changes.md の CHANGES セクションを表示
|
|
11
|
+
* 複数ファイルの履歴は VS Code 経路では一覧から選択させ (--file で直接指定も可)、
|
|
12
|
+
* ブラウザ経路では全ファイルをまとめて表示する。
|
|
7
13
|
*/
|
|
8
14
|
import { execFile } from "node:child_process";
|
|
9
15
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -12,8 +18,14 @@ import { isAbsolute, join } from "node:path";
|
|
|
12
18
|
import { parseArgs } from "node:util";
|
|
13
19
|
import { promisify } from "node:util";
|
|
14
20
|
import { invalidPathReason } from "../core/applier.js";
|
|
21
|
+
import { diffLines, toSideBySideRows } from "../core/diff.js";
|
|
22
|
+
import { buildReportPage } from "../core/diff-html.js";
|
|
23
|
+
import { EncodingError, decodeFile } from "../core/encoding.js";
|
|
15
24
|
import { parseChanges } from "../core/parser.js";
|
|
25
|
+
import { openInBrowser } from "../infra/browser.js";
|
|
16
26
|
import { loadConfig } from "../infra/config.js";
|
|
27
|
+
import { monacoVendorDir, startDiffServer } from "../infra/diff-server.js";
|
|
28
|
+
import { isInsideRoot } from "../infra/files.js";
|
|
17
29
|
import { historyRoot, listHistoryIds, readManifest } from "../infra/history.js";
|
|
18
30
|
import { createInterface } from "node:readline/promises";
|
|
19
31
|
import { findProjectRoot } from "../infra/root.js";
|
|
@@ -35,6 +47,14 @@ export function invalidVscodeCommandReason(cmd) {
|
|
|
35
47
|
}
|
|
36
48
|
return null;
|
|
37
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* コマンド不在の判定。ENOENT に加え、Windows では .cmd/.bat をシェル非経由で
|
|
52
|
+
* spawn できず EINVAL になる (CVE-2024-27980 対応後の Node) ため両方を対象にする。
|
|
53
|
+
*/
|
|
54
|
+
function isCommandMissing(e) {
|
|
55
|
+
const code = e?.code;
|
|
56
|
+
return code === "ENOENT" || code === "EINVAL";
|
|
57
|
+
}
|
|
38
58
|
let emptyFileCache = null;
|
|
39
59
|
function emptyFile() {
|
|
40
60
|
if (emptyFileCache === null) {
|
|
@@ -61,6 +81,20 @@ export function resolveDiffPair(root, historyDir, entry, mine) {
|
|
|
61
81
|
title: `${entry.path} (before → after)`,
|
|
62
82
|
};
|
|
63
83
|
}
|
|
84
|
+
/** --edit 用: スナップショット (before / --mine は after) vs 現在の実ファイル (編集対象) */
|
|
85
|
+
export function resolveEditPair(root, historyDir, entry, mine) {
|
|
86
|
+
if (entry.op === "delete")
|
|
87
|
+
return { error: `${entry.path} は delete のため編集対象がありません` };
|
|
88
|
+
const current = join(root, entry.path);
|
|
89
|
+
if (!existsSync(current))
|
|
90
|
+
return { error: `${entry.path} は現在存在しません (undo 済み?)` };
|
|
91
|
+
const snap = join(historyDir, mine ? "after" : "before", entry.path);
|
|
92
|
+
return {
|
|
93
|
+
left: existsSync(snap) ? snap : emptyFile(),
|
|
94
|
+
right: current,
|
|
95
|
+
title: `${entry.path} (${mine ? "適用直後" : "適用前"} → 現在 / 右側が編集対象)`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
64
98
|
async function pickEntry(entries) {
|
|
65
99
|
out("対象ファイルを選択してください:");
|
|
66
100
|
entries.forEach((e, i) => out(` ${i + 1}. ${e.op.padEnd(7)} ${e.path}`));
|
|
@@ -76,6 +110,104 @@ async function pickEntry(entries) {
|
|
|
76
110
|
rl.close();
|
|
77
111
|
}
|
|
78
112
|
}
|
|
113
|
+
/** 履歴スナップショット/実ファイルを行テキストに読む (欠損側は空 = create/delete の片側) */
|
|
114
|
+
function readLines(path) {
|
|
115
|
+
if (!existsSync(path))
|
|
116
|
+
return [];
|
|
117
|
+
return decodeFile(new Uint8Array(readFileSync(path))).lines.map((l) => l.text);
|
|
118
|
+
}
|
|
119
|
+
/** 静的差分レポートの HTML を生成する (テストから直接検証できるよう export) */
|
|
120
|
+
export function generateReportHtml(root, hdir, id, entries, mine) {
|
|
121
|
+
const leftLabel = mine ? "適用直後 (after)" : "適用前 (before)";
|
|
122
|
+
const rightLabel = mine ? "現在のファイル" : "適用後 (after)";
|
|
123
|
+
const sections = entries.map((e) => {
|
|
124
|
+
if (mine && e.op === "delete") {
|
|
125
|
+
return {
|
|
126
|
+
path: e.path,
|
|
127
|
+
op: e.op,
|
|
128
|
+
body: { kind: "note", note: "delete のため --mine の比較対象がありません" },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const leftPath = join(hdir, mine ? "after" : "before", e.path);
|
|
132
|
+
const rightPath = mine ? join(root, e.path) : join(hdir, "after", e.path);
|
|
133
|
+
if (mine && !existsSync(rightPath)) {
|
|
134
|
+
return {
|
|
135
|
+
path: e.path,
|
|
136
|
+
op: e.op,
|
|
137
|
+
body: { kind: "note", note: "現在存在しません (undo 済み?)" },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const a = readLines(leftPath);
|
|
142
|
+
const b = readLines(rightPath);
|
|
143
|
+
return {
|
|
144
|
+
path: e.path,
|
|
145
|
+
op: e.op,
|
|
146
|
+
body: { kind: "rows", rows: toSideBySideRows(a, b, diffLines(a, b)), leftLabel, rightLabel },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch (cause) {
|
|
150
|
+
const msg = cause instanceof EncodingError ? cause.message : "テキストとして表示できません";
|
|
151
|
+
return { path: e.path, op: e.op, body: { kind: "note", note: `差分を表示できません: ${msg}` } };
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const title = `petari 履歴 ${id}${mine ? " (手修正分)" : ""}`;
|
|
155
|
+
return buildReportPage(title, new Date().toLocaleString("ja-JP"), sections);
|
|
156
|
+
}
|
|
157
|
+
/** 静的レポートを一時ファイルへ書き出してブラウザで開く */
|
|
158
|
+
async function runBrowserReport(root, hdir, id, entries, mine, noOpen) {
|
|
159
|
+
const html = generateReportHtml(root, hdir, id, entries, mine);
|
|
160
|
+
const reportPath = join(mkdtempSync(join(tmpdir(), "petari-show-")), "report.html");
|
|
161
|
+
writeFileSync(reportPath, html);
|
|
162
|
+
out(`差分レポートを書き出しました: ${reportPath}`);
|
|
163
|
+
if (!noOpen) {
|
|
164
|
+
try {
|
|
165
|
+
await openInBrowser(reportPath);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
out("ブラウザを開けませんでした。上記のファイルを直接開いてください");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
/** ブラウザ編集サーバーを起動し、終了 (quit ボタン / 無操作 / Ctrl+C) まで待つ */
|
|
174
|
+
async function runEditServer(root, hdir, id, entries, mine, config, noOpen) {
|
|
175
|
+
if (!existsSync(join(monacoVendorDir(), "vs", "loader.js"))) {
|
|
176
|
+
err("petari: ブラウザ編集用の同梱エディタ (Monaco) が見つかりません");
|
|
177
|
+
err(" 開発環境では pnpm gen:monaco を実行してください");
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
const sub = mine ? "after" : "before";
|
|
181
|
+
const editEntries = entries.map((e) => {
|
|
182
|
+
const snap = join(hdir, sub, e.path);
|
|
183
|
+
return {
|
|
184
|
+
path: e.path,
|
|
185
|
+
absPath: join(root, e.path),
|
|
186
|
+
snapshotPath: existsSync(snap) ? snap : null,
|
|
187
|
+
op: e.op,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
const handle = await startDiffServer({
|
|
191
|
+
title: `petari 履歴 ${id} の編集`,
|
|
192
|
+
entries: editEntries,
|
|
193
|
+
root,
|
|
194
|
+
leftLabel: mine ? "適用直後 (after)" : "適用前 (before)",
|
|
195
|
+
fallbackEol: config.newFile.eol,
|
|
196
|
+
});
|
|
197
|
+
out(`ブラウザ編集サーバーを起動しました: ${handle.url}`);
|
|
198
|
+
out(" 終了はページ内の「サーバーを終了」ボタンか Ctrl+C (無操作 30 分で自動終了)");
|
|
199
|
+
if (!noOpen) {
|
|
200
|
+
try {
|
|
201
|
+
await openInBrowser(handle.url);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
out("ブラウザを開けませんでした。上記の URL を直接開いてください");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const reason = await handle.closed;
|
|
208
|
+
out(reason === "quit" ? "petari: 編集サーバーを終了しました" : "petari: 無操作のため編集サーバーを自動終了しました");
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
79
211
|
export async function showCommand(argv) {
|
|
80
212
|
const { values, positionals } = parseArgs({
|
|
81
213
|
args: argv,
|
|
@@ -84,11 +216,15 @@ export async function showCommand(argv) {
|
|
|
84
216
|
mine: { type: "boolean", default: false },
|
|
85
217
|
changes: { type: "boolean", default: false },
|
|
86
218
|
file: { type: "string" },
|
|
219
|
+
edit: { type: "boolean", default: false },
|
|
220
|
+
browser: { type: "boolean", default: false },
|
|
221
|
+
"no-open": { type: "boolean", default: false },
|
|
87
222
|
},
|
|
88
223
|
allowPositionals: true,
|
|
89
224
|
});
|
|
90
225
|
const root = findProjectRoot(process.cwd(), values.root);
|
|
91
226
|
const config = loadConfig(root);
|
|
227
|
+
const noOpen = values["no-open"];
|
|
92
228
|
const ids = listHistoryIds(root);
|
|
93
229
|
if (ids.length === 0) {
|
|
94
230
|
err("petari: 履歴がありません");
|
|
@@ -113,50 +249,80 @@ export async function showCommand(argv) {
|
|
|
113
249
|
err(`petari: 履歴 ${id} に適用済みファイルがありません`);
|
|
114
250
|
return 1;
|
|
115
251
|
}
|
|
116
|
-
|
|
252
|
+
// manifest は改ざんされ得るため、対象パスを全件検証する (§9。読み取り側にも適用)
|
|
253
|
+
for (const e of applied) {
|
|
254
|
+
const pathReason = invalidPathReason(e.path);
|
|
255
|
+
if (pathReason !== null) {
|
|
256
|
+
err(`petari: manifest のパスが不正です: ${e.path} (${pathReason})`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// --edit は実ファイルへ書き込むため、undo と同じく symlink 経由のルート外も拒否する
|
|
261
|
+
if (values.edit) {
|
|
262
|
+
for (const e of applied) {
|
|
263
|
+
if (!isInsideRoot(root, join(root, e.path))) {
|
|
264
|
+
err(`petari: manifest のパスがプロジェクト外を指しています: ${e.path}`);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let fileEntry = null;
|
|
117
270
|
if (values.file !== undefined) {
|
|
118
271
|
const found = applied.find((e) => e.path === values.file);
|
|
119
272
|
if (found === undefined) {
|
|
120
273
|
err(`petari: ${values.file} は履歴 ${id} にありません`);
|
|
121
274
|
return 1;
|
|
122
275
|
}
|
|
123
|
-
|
|
276
|
+
fileEntry = found;
|
|
124
277
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
err("petari: 中止しました");
|
|
278
|
+
const browserEntries = fileEntry !== null ? [fileEntry] : applied;
|
|
279
|
+
if (!values.browser) {
|
|
280
|
+
// VS Code 経路。壊れた vscodeCommand 設定はここで止める (--browser には影響させない)
|
|
281
|
+
const cmdReason = invalidVscodeCommandReason(config.vscodeCommand);
|
|
282
|
+
if (cmdReason !== null) {
|
|
283
|
+
err(`petari: ${cmdReason}: ${config.vscodeCommand}`);
|
|
132
284
|
return 1;
|
|
133
285
|
}
|
|
134
|
-
entry
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
286
|
+
let entry;
|
|
287
|
+
if (fileEntry !== null) {
|
|
288
|
+
entry = fileEntry;
|
|
289
|
+
}
|
|
290
|
+
else if (applied.length === 1) {
|
|
291
|
+
entry = applied[0];
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const picked = await pickEntry(applied);
|
|
295
|
+
if (picked === null) {
|
|
296
|
+
err("petari: 中止しました");
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
entry = picked;
|
|
300
|
+
}
|
|
301
|
+
const pair = values.edit
|
|
302
|
+
? resolveEditPair(root, hdir, entry, values.mine)
|
|
303
|
+
: resolveDiffPair(root, hdir, entry, values.mine);
|
|
304
|
+
if ("error" in pair) {
|
|
305
|
+
err(`petari: ${pair.error}`);
|
|
306
|
+
return 1;
|
|
307
|
+
}
|
|
308
|
+
out(`${config.vscodeCommand} --diff で開きます: ${pair.title}`);
|
|
309
|
+
try {
|
|
310
|
+
await execFileP(config.vscodeCommand, ["--diff", pair.left, pair.right]);
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
if (!isCommandMissing(e)) {
|
|
315
|
+
err(`petari: ${config.vscodeCommand} の起動に失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
316
|
+
err(" .petari/config.json の vscodeCommand を確認してください");
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
out(`${config.vscodeCommand} が見つからないためブラウザで表示します`);
|
|
320
|
+
// フォールバック: 選択済みの 1 件ではなく全ファイルをまとめて表示する
|
|
321
|
+
// (レポート/編集サーバーはページ内ナビで全件を扱えるため)
|
|
322
|
+
}
|
|
155
323
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
err(" .petari/config.json の vscodeCommand を確認してください");
|
|
159
|
-
return 1;
|
|
324
|
+
if (values.edit) {
|
|
325
|
+
return runEditServer(root, hdir, id, browserEntries, values.mine, config, noOpen);
|
|
160
326
|
}
|
|
161
|
-
return
|
|
327
|
+
return runBrowserReport(root, hdir, id, browserEntries, values.mine, noOpen);
|
|
162
328
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ブラウザ差分ビューの HTML 生成 (§4.3)。純粋な文字列生成のみ (I/O なし)。
|
|
3
|
+
*
|
|
4
|
+
* セキュリティ方針:
|
|
5
|
+
* - 補間箇所は全て escapeHtml を通す。ファイル内容は非信頼入力 (AI 出力) として扱い、
|
|
6
|
+
* </textarea> や <script> の注入を無効化する
|
|
7
|
+
* - JS は一切使わない (フォーム POST のみ)。外部リソース参照ゼロ・インライン CSS のみ。
|
|
8
|
+
* 未変更範囲の折りたたみも <details> (JS 不要) で行う
|
|
9
|
+
* - textarea の値はエスケープ以外の加工禁止。制御文字を除去すると無編集行が
|
|
10
|
+
* 「変更」扱いになり、保存時にユーザーが触っていない行の文字が消えるため。
|
|
11
|
+
* 読み取り専用の diff ペインのみ表示用に双方向制御文字等を除去する
|
|
12
|
+
* - フォームの action とリンクは相対パスのみ (URL のトークンを HTML 本文に埋めない)
|
|
13
|
+
*/
|
|
14
|
+
import { intralineRanges } from "./diff.js";
|
|
15
|
+
/** 1 ファイルの diff 表示上限行数 (巨大ファイルでのメモリ・描画保護) */
|
|
16
|
+
export const MAX_SECTION_ROWS = 20_000;
|
|
17
|
+
/** 変更行の前後に見せる未変更行数と、折りたたみを発動する最小行数 */
|
|
18
|
+
const FOLD_CONTEXT = 3;
|
|
19
|
+
const FOLD_MIN = 10;
|
|
20
|
+
export function escapeHtml(s) {
|
|
21
|
+
return s
|
|
22
|
+
.replaceAll("&", "&")
|
|
23
|
+
.replaceAll("<", "<")
|
|
24
|
+
.replaceAll(">", ">")
|
|
25
|
+
.replaceAll('"', """)
|
|
26
|
+
.replaceAll("'", "'");
|
|
27
|
+
}
|
|
28
|
+
// 表示専用の無害化 (term.ts と同じ集合: C0/C1 制御と Trojan Source 双方向制御)。
|
|
29
|
+
// textarea には適用しないこと (上記セキュリティ方針)
|
|
30
|
+
const DISPLAY_CONTROL_CHARS = new RegExp("[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]", "g");
|
|
31
|
+
function displayText(s) {
|
|
32
|
+
return escapeHtml(s.replace(DISPLAY_CONTROL_CHARS, ""));
|
|
33
|
+
}
|
|
34
|
+
/** 全ページ共通の CSP。サーバー応答ヘッダにも同じ値を使う (meta の form-action 等は
|
|
35
|
+
* 無視されるブラウザがあるため、サーバー配信時はヘッダ側が正) */
|
|
36
|
+
export const CSP = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
37
|
+
const STYLE = `
|
|
38
|
+
:root{
|
|
39
|
+
--bg:#ffffff;--fg:#1f2328;--muted:#59636e;--border:#d1d9e0;--surface:#f6f8fa;
|
|
40
|
+
--accent:#0969da;--ok:#1a7f37;--err:#d1242f;--rewrite:#8250df;
|
|
41
|
+
--del-bg:#ffebe9;--del-hl:#ffb3ad;--ins-bg:#dafbe1;--ins-hl:#a5e8b7;
|
|
42
|
+
--radius:8px;
|
|
43
|
+
}
|
|
44
|
+
@media (prefers-color-scheme: dark){:root{
|
|
45
|
+
--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--border:#30363d;--surface:#161b22;
|
|
46
|
+
--accent:#4493f8;--ok:#3fb950;--err:#f85149;--rewrite:#ab7df8;
|
|
47
|
+
--del-bg:#3b1a1d;--del-hl:#792e34;--ins-bg:#122b1d;--ins-hl:#1c5b34;
|
|
48
|
+
}}
|
|
49
|
+
*{box-sizing:border-box}
|
|
50
|
+
body{margin:0;background:var(--bg);color:var(--fg);line-height:1.5;
|
|
51
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Hiragino Sans","Noto Sans JP",sans-serif}
|
|
52
|
+
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
53
|
+
.wrap{max-width:1180px;margin:0 auto;padding:1.2rem 1.4rem 4rem}
|
|
54
|
+
header.page h1{font-size:1.25rem;margin:.2rem 0}
|
|
55
|
+
header.page .meta{color:var(--muted);font-size:.82rem;margin:.1rem 0 0}
|
|
56
|
+
nav.files{position:sticky;top:0;z-index:2;display:flex;flex-wrap:wrap;gap:.4rem;
|
|
57
|
+
padding:.6rem 0;margin:.4rem 0 .2rem;background:var(--bg);border-bottom:1px solid var(--border)}
|
|
58
|
+
nav.files a{font-family:ui-monospace,Menlo,monospace;font-size:.78rem;text-decoration:none;
|
|
59
|
+
color:var(--accent);background:var(--surface);border:1px solid var(--border);
|
|
60
|
+
border-radius:2em;padding:.18em .7em}
|
|
61
|
+
nav.files a:hover{border-color:var(--accent)}
|
|
62
|
+
section.file{border:1px solid var(--border);border-radius:var(--radius);margin:1rem 0;
|
|
63
|
+
overflow:hidden;background:var(--bg)}
|
|
64
|
+
.fhead{display:flex;align-items:center;gap:.6em;padding:.55rem .8rem;
|
|
65
|
+
background:var(--surface);border-bottom:1px solid var(--border)}
|
|
66
|
+
.fhead code{font-size:.88rem;font-weight:600;word-break:break-all}
|
|
67
|
+
.op{font-size:.68rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
|
|
68
|
+
padding:.12em .6em;border-radius:2em;flex-shrink:0}
|
|
69
|
+
.op-replace{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}
|
|
70
|
+
.op-create{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}
|
|
71
|
+
.op-delete{color:var(--err);background:color-mix(in srgb,var(--err) 14%,transparent)}
|
|
72
|
+
.op-rewrite{color:var(--rewrite);background:color-mix(in srgb,var(--rewrite) 14%,transparent)}
|
|
73
|
+
.dlabels{display:grid;grid-template-columns:1fr 1fr;font-size:.74rem;color:var(--muted);
|
|
74
|
+
background:var(--surface);border-bottom:1px solid var(--border)}
|
|
75
|
+
.dlabels div{padding:.28rem .7rem}
|
|
76
|
+
.dlabels div+div{border-left:1px solid var(--border)}
|
|
77
|
+
table.diff{border-collapse:collapse;width:100%;table-layout:fixed;
|
|
78
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.5}
|
|
79
|
+
col.cno{width:3.4em}
|
|
80
|
+
.diff td{vertical-align:top;padding:0 .55em}
|
|
81
|
+
.diff td.no{text-align:right;color:var(--muted);user-select:none;background:var(--surface);
|
|
82
|
+
border-right:1px solid var(--border);padding:0 .5em}
|
|
83
|
+
.diff td.no.split{border-left:1px solid var(--border)}
|
|
84
|
+
.diff td.code{white-space:pre-wrap;word-break:break-all}
|
|
85
|
+
.diff td.code.del{background:var(--del-bg)}
|
|
86
|
+
.diff td.code.ins{background:var(--ins-bg)}
|
|
87
|
+
.code.del .hl{background:var(--del-hl);border-radius:2px}
|
|
88
|
+
.code.ins .hl{background:var(--ins-hl);border-radius:2px}
|
|
89
|
+
.diff td.code.empty{background:repeating-linear-gradient(45deg,var(--surface),
|
|
90
|
+
var(--surface) 4px,var(--bg) 4px,var(--bg) 9px)}
|
|
91
|
+
details.fold summary{cursor:pointer;list-style:none;text-align:center;font-size:.76rem;
|
|
92
|
+
color:var(--accent);background:var(--surface);padding:.28rem .6rem;
|
|
93
|
+
border-top:1px solid var(--border);border-bottom:1px solid var(--border);user-select:none}
|
|
94
|
+
details.fold summary::-webkit-details-marker{display:none}
|
|
95
|
+
details.fold summary:hover{text-decoration:underline}
|
|
96
|
+
details.fold[open] summary{border-bottom-style:dashed}
|
|
97
|
+
p.note{color:var(--muted);font-size:.84rem;margin:.6rem .8rem}
|
|
98
|
+
.banner{border-radius:6px;padding:.55rem .9rem;margin:.7rem 0;font-size:.88rem;border:1px solid}
|
|
99
|
+
.banner.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);
|
|
100
|
+
background:color-mix(in srgb,var(--ok) 10%,transparent)}
|
|
101
|
+
.banner.err{color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);
|
|
102
|
+
background:color-mix(in srgb,var(--err) 10%,transparent)}
|
|
103
|
+
section.editor{margin:1rem 0}
|
|
104
|
+
textarea{width:100%;min-height:24rem;padding:.8rem;resize:vertical;
|
|
105
|
+
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.5;
|
|
106
|
+
color:var(--fg);background:var(--bg);border:1px solid var(--border);border-radius:var(--radius)}
|
|
107
|
+
textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
108
|
+
button{font-size:.88rem;font-weight:600;padding:.45em 1.4em;border-radius:6px;cursor:pointer;
|
|
109
|
+
border:1px solid transparent;background:var(--accent);color:#fff}
|
|
110
|
+
button:hover{filter:brightness(1.1)}
|
|
111
|
+
button.secondary{background:var(--surface);color:var(--muted);border-color:var(--border)}
|
|
112
|
+
.actions{display:flex;align-items:center;gap:.8rem;margin:.7rem 0}
|
|
113
|
+
.footer-actions{display:flex;align-items:center;gap:1rem;margin:1.6rem 0 0;
|
|
114
|
+
padding-top:.9rem;border-top:1px solid var(--border)}
|
|
115
|
+
.footer-actions form{margin:0}
|
|
116
|
+
a.back{color:var(--accent);text-decoration:none;font-size:.88rem}
|
|
117
|
+
a.back:hover{text-decoration:underline}
|
|
118
|
+
ul.list{list-style:none;margin:.8rem 0;padding:0;border:1px solid var(--border);
|
|
119
|
+
border-radius:var(--radius);overflow:hidden}
|
|
120
|
+
ul.list li{display:flex;align-items:center;gap:.7em;padding:.6rem .9rem;background:var(--bg)}
|
|
121
|
+
ul.list li+li{border-top:1px solid var(--border)}
|
|
122
|
+
ul.list a{color:var(--accent);text-decoration:none;font-family:ui-monospace,Menlo,monospace;
|
|
123
|
+
font-size:.9rem;word-break:break-all}
|
|
124
|
+
ul.list a:hover{text-decoration:underline}
|
|
125
|
+
ul.list .note{color:var(--muted);font-size:.78rem;margin-left:auto;flex-shrink:0}
|
|
126
|
+
`.trim();
|
|
127
|
+
function page(title, bodyHtml) {
|
|
128
|
+
return `<!doctype html>
|
|
129
|
+
<html lang="ja">
|
|
130
|
+
<head>
|
|
131
|
+
<meta charset="utf-8">
|
|
132
|
+
<meta http-equiv="Content-Security-Policy" content="${CSP}">
|
|
133
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
134
|
+
<title>${escapeHtml(title)}</title>
|
|
135
|
+
<style>${STYLE}</style>
|
|
136
|
+
</head>
|
|
137
|
+
<body>
|
|
138
|
+
<div class="wrap">
|
|
139
|
+
${bodyHtml}
|
|
140
|
+
</div>
|
|
141
|
+
</body>
|
|
142
|
+
</html>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
function opBadge(op) {
|
|
146
|
+
return `<span class="op op-${op}">${op}</span>`;
|
|
147
|
+
}
|
|
148
|
+
/** 文字単位ハイライト付きでコードを描画する (範囲は元テキストのコードポイント index) */
|
|
149
|
+
function renderCode(text, ranges) {
|
|
150
|
+
if (ranges.length === 0)
|
|
151
|
+
return displayText(text);
|
|
152
|
+
const chars = Array.from(text);
|
|
153
|
+
const parts = [];
|
|
154
|
+
let pos = 0;
|
|
155
|
+
for (const r of ranges) {
|
|
156
|
+
if (r.start > pos)
|
|
157
|
+
parts.push(displayText(chars.slice(pos, r.start).join("")));
|
|
158
|
+
parts.push(`<span class="hl">${displayText(chars.slice(r.start, r.end).join(""))}</span>`);
|
|
159
|
+
pos = r.end;
|
|
160
|
+
}
|
|
161
|
+
if (pos < chars.length)
|
|
162
|
+
parts.push(displayText(chars.slice(pos).join("")));
|
|
163
|
+
return parts.join("");
|
|
164
|
+
}
|
|
165
|
+
const COLGROUP = `<colgroup><col class="cno"><col><col class="cno"><col></colgroup>`;
|
|
166
|
+
function renderRow(row) {
|
|
167
|
+
const intra = row.kind === "change" && row.left !== null && row.right !== null
|
|
168
|
+
? intralineRanges(row.left.text, row.right.text)
|
|
169
|
+
: null;
|
|
170
|
+
const cell = (side, cls, ranges, split) => {
|
|
171
|
+
const noCls = split ? "no split" : "no";
|
|
172
|
+
return side === null
|
|
173
|
+
? `<td class="${noCls}"></td><td class="code empty"></td>`
|
|
174
|
+
: `<td class="${noCls}">${side.no}</td><td class="code${cls}">${renderCode(side.text, ranges)}</td>`;
|
|
175
|
+
};
|
|
176
|
+
const delCls = row.kind === "del" || row.kind === "change" ? " del" : "";
|
|
177
|
+
const insCls = row.kind === "ins" || row.kind === "change" ? " ins" : "";
|
|
178
|
+
return `<tr>${cell(row.left, delCls, intra?.left ?? [], false)}${cell(row.right, insCls, intra?.right ?? [], true)}</tr>`;
|
|
179
|
+
}
|
|
180
|
+
/** 変更行の前後 FOLD_CONTEXT 行だけを見せ、長い未変更ランは折りたたみ対象にする */
|
|
181
|
+
function segmentRows(rows) {
|
|
182
|
+
const keep = new Array(rows.length).fill(false);
|
|
183
|
+
rows.forEach((r, i) => {
|
|
184
|
+
if (r.kind !== "same") {
|
|
185
|
+
const from = Math.max(0, i - FOLD_CONTEXT);
|
|
186
|
+
const to = Math.min(rows.length - 1, i + FOLD_CONTEXT);
|
|
187
|
+
for (let j = from; j <= to; j++)
|
|
188
|
+
keep[j] = true;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
const segs = [];
|
|
192
|
+
let i = 0;
|
|
193
|
+
while (i < rows.length) {
|
|
194
|
+
const k = keep[i];
|
|
195
|
+
let j = i;
|
|
196
|
+
while (j < rows.length && keep[j] === k)
|
|
197
|
+
j++;
|
|
198
|
+
const chunk = rows.slice(i, j);
|
|
199
|
+
segs.push({ fold: !k && chunk.length >= FOLD_MIN, rows: chunk });
|
|
200
|
+
i = j;
|
|
201
|
+
}
|
|
202
|
+
return segs;
|
|
203
|
+
}
|
|
204
|
+
function renderDiffBlocks(rows, leftLabel, rightLabel) {
|
|
205
|
+
const shown = rows.slice(0, MAX_SECTION_ROWS);
|
|
206
|
+
const omitted = rows.length - shown.length;
|
|
207
|
+
const parts = [
|
|
208
|
+
`<div class="dlabels"><div>${escapeHtml(leftLabel)}</div><div>${escapeHtml(rightLabel)}</div></div>`,
|
|
209
|
+
];
|
|
210
|
+
if (shown.every((r) => r.kind === "same") && shown.length > 0) {
|
|
211
|
+
parts.push(`<p class="note">変更はありません (全 ${shown.length} 行一致)</p>`);
|
|
212
|
+
}
|
|
213
|
+
for (const seg of segmentRows(shown)) {
|
|
214
|
+
const table = `<table class="diff">${COLGROUP}${seg.rows.map(renderRow).join("\n")}</table>`;
|
|
215
|
+
if (seg.fold) {
|
|
216
|
+
const from = seg.rows[0]?.left?.no ?? seg.rows[0]?.right?.no ?? 0;
|
|
217
|
+
parts.push(`<details class="fold"><summary>未変更の ${seg.rows.length} 行を表示 (${from} 行目〜)</summary>${table}</details>`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
parts.push(table);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (omitted > 0)
|
|
224
|
+
parts.push(`<p class="note">以降 ${omitted} 行を省略しました</p>`);
|
|
225
|
+
return parts.join("\n");
|
|
226
|
+
}
|
|
227
|
+
function renderFileSection(s, id) {
|
|
228
|
+
const inner = s.body.kind === "note"
|
|
229
|
+
? `<p class="note">${escapeHtml(s.body.note)}</p>`
|
|
230
|
+
: renderDiffBlocks(s.body.rows, s.body.leftLabel, s.body.rightLabel);
|
|
231
|
+
const anchor = id === null ? "" : ` id="${id}"`;
|
|
232
|
+
return `<section class="file"${anchor}>
|
|
233
|
+
<div class="fhead">${opBadge(s.op)}<code>${escapeHtml(s.path)}</code></div>
|
|
234
|
+
${inner}
|
|
235
|
+
</section>`;
|
|
236
|
+
}
|
|
237
|
+
/** 静的差分レポート (全ファイルを 1 ページに)。閲覧専用・自己完結 */
|
|
238
|
+
export function buildReportPage(title, generatedAt, sections) {
|
|
239
|
+
const nav = sections.length > 1
|
|
240
|
+
? `<nav class="files">${sections
|
|
241
|
+
.map((s, i) => `<a href="#f${i}">${escapeHtml(s.path)}</a>`)
|
|
242
|
+
.join("")}</nav>`
|
|
243
|
+
: "";
|
|
244
|
+
const body = sections.map((s, i) => renderFileSection(s, `f${i}`)).join("\n");
|
|
245
|
+
return page(title, `<header class="page"><h1>${escapeHtml(title)}</h1>
|
|
246
|
+
<p class="meta">${escapeHtml(generatedAt)} ・ ${sections.length} ファイル</p></header>
|
|
247
|
+
${nav}
|
|
248
|
+
${body}`);
|
|
249
|
+
}
|
|
250
|
+
/** 編集サーバーのファイル一覧ページ */
|
|
251
|
+
export function buildIndexPage(title, items, idleMinutes) {
|
|
252
|
+
const list = items
|
|
253
|
+
.map((item, i) => {
|
|
254
|
+
const note = item.note === null ? "" : `<span class="note">${escapeHtml(item.note)}</span>`;
|
|
255
|
+
return `<li>${opBadge(item.op)}<a href="f/${i}">${escapeHtml(item.path)}</a>${note}</li>`;
|
|
256
|
+
})
|
|
257
|
+
.join("\n");
|
|
258
|
+
const body = `<header class="page"><h1>${escapeHtml(title)}</h1>
|
|
259
|
+
<p class="meta">無操作 ${idleMinutes} 分でサーバーは自動終了します。編集途中の内容は保存されません。</p></header>
|
|
260
|
+
<ul class="list">
|
|
261
|
+
${list}
|
|
262
|
+
</ul>
|
|
263
|
+
<div class="footer-actions"><form method="post" action="quit"><button class="secondary">サーバーを終了</button></form></div>`;
|
|
264
|
+
return page(title, body);
|
|
265
|
+
}
|
|
266
|
+
/** エラー・終了などの単文ページ (409 / 404 / 終了しました 等) */
|
|
267
|
+
export function buildMessagePage(title, message, backHref) {
|
|
268
|
+
const back = backHref === null
|
|
269
|
+
? ""
|
|
270
|
+
: `\n<div class="footer-actions"><a class="back" href="${escapeHtml(backHref)}">← 戻る</a></div>`;
|
|
271
|
+
return page(title, `<header class="page"><h1>${escapeHtml(title)}</h1></header>\n<p>${escapeHtml(message)}</p>${back}`);
|
|
272
|
+
}
|