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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行単位の diff (§4.3 ブラウザ差分ビュー用)。
|
|
3
|
+
*
|
|
4
|
+
* 不変条件: どんな入力でも「ops を a に適用すると b が完全再現される」。
|
|
5
|
+
* Myers の探索を maxD で打ち切った場合は中間部を全削除+全挿入として返すため、
|
|
6
|
+
* diff の品質はマージ (core/edit.ts) の正しさに影響しない — 打ち切りは
|
|
7
|
+
* raw バイトを保持できる行が減るだけ。
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_MAX_D = 2000;
|
|
10
|
+
/** 行配列 a → b の差分を返す。インデックスはともに 0-based */
|
|
11
|
+
export function diffLines(a, b, maxD = DEFAULT_MAX_D) {
|
|
12
|
+
// 共通の前置き・後置きを先に確定する (パッチ適用前後の比較では大半が共通)
|
|
13
|
+
let pre = 0;
|
|
14
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre])
|
|
15
|
+
pre++;
|
|
16
|
+
let suf = 0;
|
|
17
|
+
while (suf < a.length - pre &&
|
|
18
|
+
suf < b.length - pre &&
|
|
19
|
+
a[a.length - 1 - suf] === b[b.length - 1 - suf]) {
|
|
20
|
+
suf++;
|
|
21
|
+
}
|
|
22
|
+
const ops = [];
|
|
23
|
+
for (let i = 0; i < pre; i++)
|
|
24
|
+
ops.push({ kind: "same", a: i, b: i });
|
|
25
|
+
for (const op of diffMiddle(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf), maxD)) {
|
|
26
|
+
if (op.kind === "same")
|
|
27
|
+
ops.push({ kind: "same", a: op.a + pre, b: op.b + pre });
|
|
28
|
+
else if (op.kind === "del")
|
|
29
|
+
ops.push({ kind: "del", a: op.a + pre });
|
|
30
|
+
else
|
|
31
|
+
ops.push({ kind: "ins", b: op.b + pre });
|
|
32
|
+
}
|
|
33
|
+
for (let i = 0; i < suf; i++) {
|
|
34
|
+
ops.push({ kind: "same", a: a.length - suf + i, b: b.length - suf + i });
|
|
35
|
+
}
|
|
36
|
+
return ops;
|
|
37
|
+
}
|
|
38
|
+
/** 全削除 + 全挿入 (共通行の探索なし)。maxD 打ち切り時のフォールバック */
|
|
39
|
+
function fallbackAll(n, m) {
|
|
40
|
+
const ops = [];
|
|
41
|
+
for (let i = 0; i < n; i++)
|
|
42
|
+
ops.push({ kind: "del", a: i });
|
|
43
|
+
for (let j = 0; j < m; j++)
|
|
44
|
+
ops.push({ kind: "ins", b: j });
|
|
45
|
+
return ops;
|
|
46
|
+
}
|
|
47
|
+
/** Myers O(ND) 貪欲法。a と b は共通の前置き・後置きを持たない前提 */
|
|
48
|
+
function diffMiddle(a, b, maxD) {
|
|
49
|
+
const n = a.length;
|
|
50
|
+
const m = b.length;
|
|
51
|
+
if (n === 0 || m === 0)
|
|
52
|
+
return fallbackAll(n, m);
|
|
53
|
+
// |k| <= d <= cap なので V 配列は 2*cap+1 で足りる (スナップショット総量の上限も抑える)
|
|
54
|
+
const cap = Math.min(n + m, maxD);
|
|
55
|
+
const offset = cap;
|
|
56
|
+
const v = new Int32Array(2 * cap + 1);
|
|
57
|
+
const trace = [];
|
|
58
|
+
let found = -1;
|
|
59
|
+
outer: for (let d = 0; d <= cap; d++) {
|
|
60
|
+
trace.push(v.slice());
|
|
61
|
+
for (let k = -d; k <= d; k += 2) {
|
|
62
|
+
let x;
|
|
63
|
+
if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) {
|
|
64
|
+
x = v[offset + k + 1];
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
x = v[offset + k - 1] + 1;
|
|
68
|
+
}
|
|
69
|
+
let y = x - k;
|
|
70
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
71
|
+
x++;
|
|
72
|
+
y++;
|
|
73
|
+
}
|
|
74
|
+
v[offset + k] = x;
|
|
75
|
+
if (x >= n && y >= m) {
|
|
76
|
+
found = d;
|
|
77
|
+
break outer;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (found === -1)
|
|
82
|
+
return fallbackAll(n, m);
|
|
83
|
+
// バックトラック (trace[d] は深さ d-1 終了時点の V)
|
|
84
|
+
const rev = [];
|
|
85
|
+
let x = n;
|
|
86
|
+
let y = m;
|
|
87
|
+
for (let d = found; d > 0; d--) {
|
|
88
|
+
const vp = trace[d];
|
|
89
|
+
const k = x - y;
|
|
90
|
+
const down = k === -d || (k !== d && vp[offset + k - 1] < vp[offset + k + 1]);
|
|
91
|
+
const prevK = down ? k + 1 : k - 1;
|
|
92
|
+
const prevX = vp[offset + prevK];
|
|
93
|
+
const prevY = prevX - prevK;
|
|
94
|
+
while (x > prevX && y > prevY) {
|
|
95
|
+
rev.push({ kind: "same", a: x - 1, b: y - 1 });
|
|
96
|
+
x--;
|
|
97
|
+
y--;
|
|
98
|
+
}
|
|
99
|
+
rev.push(down ? { kind: "ins", b: prevY } : { kind: "del", a: prevX });
|
|
100
|
+
x = prevX;
|
|
101
|
+
y = prevY;
|
|
102
|
+
}
|
|
103
|
+
while (x > 0 && y > 0) {
|
|
104
|
+
rev.push({ kind: "same", a: x - 1, b: y - 1 });
|
|
105
|
+
x--;
|
|
106
|
+
y--;
|
|
107
|
+
}
|
|
108
|
+
return rev.reverse();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* change ペア行の文字単位ハイライト範囲を両側分返す (表示専用)。
|
|
112
|
+
* 長い行や乖離が大きい行は行全体の範囲にフォールバックする (品質のみ低下)。
|
|
113
|
+
*/
|
|
114
|
+
export function intralineRanges(a, b) {
|
|
115
|
+
const A = Array.from(a);
|
|
116
|
+
const B = Array.from(b);
|
|
117
|
+
if (A.length > 1000 || B.length > 1000) {
|
|
118
|
+
return {
|
|
119
|
+
left: A.length > 0 ? [{ start: 0, end: A.length }] : [],
|
|
120
|
+
right: B.length > 0 ? [{ start: 0, end: B.length }] : [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const push = (arr, i) => {
|
|
124
|
+
const last = arr[arr.length - 1];
|
|
125
|
+
if (last !== undefined && last.end === i)
|
|
126
|
+
last.end = i + 1;
|
|
127
|
+
else
|
|
128
|
+
arr.push({ start: i, end: i + 1 });
|
|
129
|
+
};
|
|
130
|
+
const left = [];
|
|
131
|
+
const right = [];
|
|
132
|
+
for (const op of diffLines(A, B, 500)) {
|
|
133
|
+
if (op.kind === "del")
|
|
134
|
+
push(left, op.a);
|
|
135
|
+
else if (op.kind === "ins")
|
|
136
|
+
push(right, op.b);
|
|
137
|
+
}
|
|
138
|
+
return { left, right };
|
|
139
|
+
}
|
|
140
|
+
/** ops を side-by-side 表示用の行に変換する (連続する del/ins は左右にペアで並べる) */
|
|
141
|
+
export function toSideBySideRows(a, b, ops) {
|
|
142
|
+
const rows = [];
|
|
143
|
+
let i = 0;
|
|
144
|
+
while (i < ops.length) {
|
|
145
|
+
const op = ops[i];
|
|
146
|
+
if (op.kind === "same") {
|
|
147
|
+
rows.push({
|
|
148
|
+
kind: "same",
|
|
149
|
+
left: { no: op.a + 1, text: a[op.a] },
|
|
150
|
+
right: { no: op.b + 1, text: b[op.b] },
|
|
151
|
+
});
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const dels = [];
|
|
156
|
+
const inss = [];
|
|
157
|
+
while (i < ops.length && ops[i].kind !== "same") {
|
|
158
|
+
const o = ops[i];
|
|
159
|
+
if (o.kind === "del")
|
|
160
|
+
dels.push(o.a);
|
|
161
|
+
else if (o.kind === "ins")
|
|
162
|
+
inss.push(o.b);
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
const len = Math.max(dels.length, inss.length);
|
|
166
|
+
for (let r = 0; r < len; r++) {
|
|
167
|
+
const ai = dels[r];
|
|
168
|
+
const bi = inss[r];
|
|
169
|
+
rows.push({
|
|
170
|
+
kind: ai !== undefined && bi !== undefined ? "change" : ai !== undefined ? "del" : "ins",
|
|
171
|
+
left: ai !== undefined ? { no: ai + 1, text: a[ai] } : null,
|
|
172
|
+
right: bi !== undefined ? { no: bi + 1, text: b[bi] } : null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return rows;
|
|
177
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ブラウザ編集の保存マージ (§4.3 + §8)。
|
|
3
|
+
* textarea で送信された全文を FileDocument にマージする。変更していない行は
|
|
4
|
+
* 元の DocLine (raw バイト・改行) をそのまま保持し、変更・追加行のみを
|
|
5
|
+
* 再エンコード対象 (raw: null) にする — apply の replace と同じ §8 の方式。
|
|
6
|
+
*/
|
|
7
|
+
import { diffLines } from "./diff.js";
|
|
8
|
+
import { findUnencodable } from "./encoding.js";
|
|
9
|
+
/**
|
|
10
|
+
* textarea 送信テキストを行に分解する。フォーム送信の改行は CRLF に正規化されるため
|
|
11
|
+
* \r\n / \r / \n を全て区切りとして扱う。末尾の空セグメント = 末尾改行あり
|
|
12
|
+
* (decodeFile と同じ規約)。
|
|
13
|
+
*/
|
|
14
|
+
export function parseEditedText(text) {
|
|
15
|
+
if (text === "")
|
|
16
|
+
return { lines: [], trailingNewline: false };
|
|
17
|
+
const segs = text.split(/\r\n|\r|\n/);
|
|
18
|
+
if (segs[segs.length - 1] === "")
|
|
19
|
+
return { lines: segs.slice(0, -1), trailingNewline: true };
|
|
20
|
+
return { lines: segs, trailingNewline: false };
|
|
21
|
+
}
|
|
22
|
+
/** doc を textarea 表示用の全文にする (\n 結合。parseEditedText と往復可能) */
|
|
23
|
+
export function renderTextareaValue(doc) {
|
|
24
|
+
const joined = doc.lines.map((l) => l.text).join("\n");
|
|
25
|
+
return doc.trailingNewline && doc.lines.length > 0 ? joined + "\n" : joined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 編集結果を doc にマージする。行 diff で整列し、テキストが一致する行は元の
|
|
29
|
+
* DocLine をそのまま使う (raw バイト・実改行を保持)。
|
|
30
|
+
* 既知の限界 (許容済み): 同一テキストで raw バイトだけ異なる重複行 (Shift_JIS の
|
|
31
|
+
* NEC/IBM 拡張の重複マッピング等) は、他所の編集により diff の整列先が入れ替わり
|
|
32
|
+
* raw が交換されることがある。デコード結果は同一のため実害はない。
|
|
33
|
+
*/
|
|
34
|
+
export function mergeEditedText(doc, editedText) {
|
|
35
|
+
const { lines: newTexts, trailingNewline } = parseEditedText(editedText);
|
|
36
|
+
const ops = diffLines(doc.lines.map((l) => l.text), newTexts);
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const op of ops) {
|
|
39
|
+
if (op.kind === "same")
|
|
40
|
+
lines.push(doc.lines[op.a]);
|
|
41
|
+
else if (op.kind === "ins")
|
|
42
|
+
lines.push({ text: newTexts[op.b], raw: null, eol: null });
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
encoding: doc.encoding,
|
|
46
|
+
hasBom: doc.hasBom,
|
|
47
|
+
eol: doc.eol,
|
|
48
|
+
trailingNewline,
|
|
49
|
+
lines,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* ブラウザ編集できない理由を返す (null = 編集可)。
|
|
54
|
+
* HTML パーサは textarea 内の CR/CRLF を LF に正規化し NUL を U+FFFD に置換するため、
|
|
55
|
+
* これらを含むファイルは「開いて保存しただけで壊れる」— 事前に編集を拒否する。
|
|
56
|
+
* (閲覧のみの diff 表示には影響しない)
|
|
57
|
+
*/
|
|
58
|
+
export function browserEditBlockReason(doc) {
|
|
59
|
+
for (const line of doc.lines) {
|
|
60
|
+
if (line.text.includes("\r") || line.text.includes("\u0000")) {
|
|
61
|
+
return "CR のみの改行または NUL 文字を含むため、ブラウザでの編集はできません (HTML フォームが内容を変更してしまうため)";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** マージ後の再エンコード対象行 (raw: null) から変換不能文字を集める (§8 の事前検証) */
|
|
67
|
+
export function findMergeUnencodable(doc) {
|
|
68
|
+
if (doc.encoding === "utf8")
|
|
69
|
+
return [];
|
|
70
|
+
const bad = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const line of doc.lines) {
|
|
73
|
+
if (line.raw !== null)
|
|
74
|
+
continue;
|
|
75
|
+
for (const ch of findUnencodable(line.text, doc.encoding)) {
|
|
76
|
+
if (!seen.has(ch)) {
|
|
77
|
+
seen.add(ch);
|
|
78
|
+
bad.push(ch);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return bad;
|
|
83
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monaco Editor (VS Code のエディタ実装) によるブラウザ差分エディタページ (§4.3 --edit)。
|
|
3
|
+
* 純粋な文字列生成のみ。
|
|
4
|
+
*
|
|
5
|
+
* セキュリティ方針 (2026-08-12 Monaco 採用で更新):
|
|
6
|
+
* - スクリプトは「ビルド時に同梱した monaco-editor (script-src 'self')」と
|
|
7
|
+
* 「このファイルに埋め込んだ自前ブートストラップ (nonce)」のみ。
|
|
8
|
+
* 外部 CDN・外部リソースへの参照はゼロ (CSP が遮断する)
|
|
9
|
+
* - 埋め込みデータ (JSON) は JSON.stringify 後に < を < へ置換し、
|
|
10
|
+
* </script> による脱出を封じる (type="application/json" の不活性ブロック)
|
|
11
|
+
* - トークンを HTML に埋め込まない: monaco 資産のパスは実行時に location.pathname
|
|
12
|
+
* から組み立てる。フォーム action・リンクは相対のみ
|
|
13
|
+
* - JS が無効でも動く: <noscript> 内の素のフォーム (textarea) で保存できる。
|
|
14
|
+
* textarea の値はエスケープ以外の加工禁止・開きタグ直後の改行必須 (diff-html.ts と同じ理由)
|
|
15
|
+
*
|
|
16
|
+
* 実装メモ: monaco.editor.createDiffEditor が VS Code と同じ差分 UI
|
|
17
|
+
* (view zone による完全な行整列・右ペイン編集・シンタックスハイライト) を提供する。
|
|
18
|
+
* モデルの改行は monaco が単一 EOL に正規化するが、保存は §8 の mergeEditedText が
|
|
19
|
+
* 行テキスト単位で raw バイトを保全するため、無変更行の実バイトは壊れない。
|
|
20
|
+
*/
|
|
21
|
+
import { escapeHtml, CSP } from "./diff-html.js";
|
|
22
|
+
/**
|
|
23
|
+
* エディタページ用 CSP。同梱 monaco ('self') + 自前ブートストラップ (nonce) のみ許可。
|
|
24
|
+
* font-src data: は monaco の codicon フォント (CSS 内 data: URI)、
|
|
25
|
+
* worker-src は monaco のエディタ/差分計算ワーカー用。
|
|
26
|
+
*/
|
|
27
|
+
export function editorCsp(nonce) {
|
|
28
|
+
return CSP.replace("default-src 'none'; style-src 'unsafe-inline'", `default-src 'none'; script-src 'self' 'nonce-${nonce}'; style-src 'unsafe-inline' 'self'; ` +
|
|
29
|
+
"font-src data:; img-src 'self' data:; worker-src 'self' blob:; connect-src 'self'");
|
|
30
|
+
}
|
|
31
|
+
const STYLE = `
|
|
32
|
+
:root{
|
|
33
|
+
--bg:#ffffff;--panel:#f3f3f3;--border:#e5e5e5;--fg:#1f1f1f;--muted:#6e7681;
|
|
34
|
+
--accent:#005fb8;--accent-fg:#ffffff;--status-bg:#005fb8;--status-fg:#ffffff;
|
|
35
|
+
--ok:#1a7f37;--err:#c72e2e;
|
|
36
|
+
}
|
|
37
|
+
@media (prefers-color-scheme: dark){:root{
|
|
38
|
+
--bg:#1e1e1e;--panel:#252526;--border:#3c3c3c;--fg:#d4d4d4;--muted:#858585;
|
|
39
|
+
--accent:#0e639c;--accent-fg:#ffffff;--status-bg:#007acc;--status-fg:#ffffff;
|
|
40
|
+
--ok:#89d185;--err:#f48771;
|
|
41
|
+
}}
|
|
42
|
+
*{box-sizing:border-box;margin:0}
|
|
43
|
+
html,body{height:100%}
|
|
44
|
+
body{background:var(--bg);color:var(--fg);overflow:hidden;
|
|
45
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Hiragino Sans","Noto Sans JP",sans-serif;font-size:13px}
|
|
46
|
+
.app{display:flex;flex-direction:column;height:100vh}
|
|
47
|
+
.titlebar{display:flex;align-items:center;gap:.8rem;background:var(--panel);
|
|
48
|
+
border-bottom:1px solid var(--border);padding:0 .6rem 0 0;flex-shrink:0}
|
|
49
|
+
.tab{display:flex;align-items:center;gap:.45em;background:var(--bg);border-right:1px solid var(--border);
|
|
50
|
+
padding:.45em .95em;font-size:12.5px}
|
|
51
|
+
.tab code{font-family:ui-monospace,Menlo,Consolas,monospace}
|
|
52
|
+
#dirty{color:var(--fg);visibility:hidden}
|
|
53
|
+
.tb-sp{flex:1}
|
|
54
|
+
#msg{font-size:12px;color:var(--muted)}
|
|
55
|
+
#msg.ok{color:var(--ok)} #msg.err{color:var(--err)}
|
|
56
|
+
.btn{font-size:12.5px;padding:.34em 1.1em;border-radius:3px;border:1px solid transparent;cursor:pointer;
|
|
57
|
+
background:var(--accent);color:var(--accent-fg)}
|
|
58
|
+
.btn:hover{filter:brightness(1.15)}
|
|
59
|
+
.btn.ghost{background:transparent;color:var(--fg);border-color:var(--border);text-decoration:none;display:inline-block}
|
|
60
|
+
.inline{display:inline}
|
|
61
|
+
.banner{padding:.4rem .8rem;font-size:12.5px;border-bottom:1px solid var(--border)}
|
|
62
|
+
.banner.ok{color:var(--ok)} .banner.err{color:var(--err)}
|
|
63
|
+
#ed{flex:1;min-height:0}
|
|
64
|
+
.note{color:var(--muted);padding:.8rem}
|
|
65
|
+
noscript textarea{width:100%;height:60vh;font-family:ui-monospace,Menlo,Consolas,monospace;
|
|
66
|
+
font-size:12.5px;background:var(--bg);color:var(--fg);border:1px solid var(--border)}
|
|
67
|
+
noscript .nsform{padding:.8rem}
|
|
68
|
+
.statusbar{display:flex;align-items:center;gap:1.2em;background:var(--status-bg);color:var(--status-fg);
|
|
69
|
+
font-size:12px;padding:.18em .9em;flex-shrink:0}
|
|
70
|
+
.statusbar .sp{flex:1}
|
|
71
|
+
`.trim();
|
|
72
|
+
/** 埋め込み JSON: </script> 脱出と行分離子を封じる */
|
|
73
|
+
function embedJson(value) {
|
|
74
|
+
return JSON.stringify(value)
|
|
75
|
+
.replaceAll("<", "\\u003c")
|
|
76
|
+
.replaceAll("\u2028", "\\u2028")
|
|
77
|
+
.replaceAll("\u2029", "\\u2029");
|
|
78
|
+
}
|
|
79
|
+
// 埋め込みブートストラップ JS。テンプレートリテラル内のため ` と ${ は使わない
|
|
80
|
+
const BOOT_JS = String.raw `
|
|
81
|
+
(function () {
|
|
82
|
+
"use strict";
|
|
83
|
+
var data = JSON.parse(document.getElementById("pd").textContent);
|
|
84
|
+
var msgEl = document.getElementById("msg");
|
|
85
|
+
function setMsg(text, cls) {
|
|
86
|
+
msgEl.textContent = text;
|
|
87
|
+
msgEl.className = cls || "";
|
|
88
|
+
}
|
|
89
|
+
if (data.blocked) return;
|
|
90
|
+
var saveBtn = document.getElementById("savebtn");
|
|
91
|
+
var dirtyEl = document.getElementById("dirty");
|
|
92
|
+
var statEl = document.getElementById("diffstat");
|
|
93
|
+
var caretEl = document.getElementById("caret");
|
|
94
|
+
// monaco 資産のベース URL。トークンは HTML に埋めず実行時に URL から得る
|
|
95
|
+
var tokenBase = "/" + window.location.pathname.split("/").filter(function (s) { return s !== ""; })[0];
|
|
96
|
+
var baseSha = data.baseSha256;
|
|
97
|
+
var saving = false, dirty = false;
|
|
98
|
+
var modified = null;
|
|
99
|
+
var msgTimer = null;
|
|
100
|
+
|
|
101
|
+
var css = document.createElement("link");
|
|
102
|
+
css.rel = "stylesheet";
|
|
103
|
+
css.href = tokenBase + "/vs/editor/editor.main.css";
|
|
104
|
+
document.head.appendChild(css);
|
|
105
|
+
// 読み込みが黙って止まった場合の見張り (依存欠落は AMD ではエラーにならないことがある)
|
|
106
|
+
var watchdog = setTimeout(function () {
|
|
107
|
+
setMsg("エディタの読み込みに時間がかかっています (コンソールを確認してください)", "err");
|
|
108
|
+
}, 20000);
|
|
109
|
+
// 日本語 NLS: monaco 0.5x では AMD モジュールではなく素のスクリプトが
|
|
110
|
+
// globalThis._VSCODE_NLS_MESSAGES を設定する方式。editor.main より先に読み込む。
|
|
111
|
+
// (旧 availableLanguages 設定で読むと define されないモジュールを待ってハングする)
|
|
112
|
+
var nls = document.createElement("script");
|
|
113
|
+
nls.src = tokenBase + "/vs/nls/lang/ja.js";
|
|
114
|
+
nls.onload = loadLoader;
|
|
115
|
+
nls.onerror = loadLoader; // NLS がなくても英語 UI で続行
|
|
116
|
+
document.head.appendChild(nls);
|
|
117
|
+
|
|
118
|
+
function loadLoader() {
|
|
119
|
+
var s = document.createElement("script");
|
|
120
|
+
s.src = tokenBase + "/vs/loader.js";
|
|
121
|
+
s.onload = boot;
|
|
122
|
+
s.onerror = function () { setMsg("エディタ資産を読み込めませんでした", "err"); };
|
|
123
|
+
document.head.appendChild(s);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function boot() {
|
|
127
|
+
window.require.config({ paths: { vs: tokenBase + "/vs" } });
|
|
128
|
+
window.require(["vs/editor/editor.main"], init, function () {
|
|
129
|
+
setMsg("エディタを初期化できませんでした", "err");
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function init() {
|
|
134
|
+
var monaco = window.monaco;
|
|
135
|
+
var dark = window.matchMedia("(prefers-color-scheme: dark)");
|
|
136
|
+
var original = monaco.editor.createModel(
|
|
137
|
+
data.snapshotText === null ? "" : data.snapshotText,
|
|
138
|
+
undefined,
|
|
139
|
+
monaco.Uri.file("/original/" + data.path)
|
|
140
|
+
);
|
|
141
|
+
var mod = monaco.editor.createModel(
|
|
142
|
+
data.currentText,
|
|
143
|
+
undefined,
|
|
144
|
+
monaco.Uri.file("/modified/" + data.path)
|
|
145
|
+
);
|
|
146
|
+
modified = mod;
|
|
147
|
+
var diffEditor = monaco.editor.createDiffEditor(document.getElementById("ed"), {
|
|
148
|
+
automaticLayout: true,
|
|
149
|
+
originalEditable: false,
|
|
150
|
+
renderSideBySide: true,
|
|
151
|
+
ignoreTrimWhitespace: false,
|
|
152
|
+
theme: dark.matches ? "vs-dark" : "vs",
|
|
153
|
+
});
|
|
154
|
+
diffEditor.setModel({ original: original, modified: mod });
|
|
155
|
+
if (dark.addEventListener) {
|
|
156
|
+
dark.addEventListener("change", function (e) {
|
|
157
|
+
monaco.editor.setTheme(e.matches ? "vs-dark" : "vs");
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
mod.onDidChangeContent(function () {
|
|
161
|
+
if (!dirty) { dirty = true; dirtyEl.style.visibility = "visible"; }
|
|
162
|
+
});
|
|
163
|
+
diffEditor.onDidUpdateDiff(function () {
|
|
164
|
+
var changes = diffEditor.getLineChanges() || [];
|
|
165
|
+
var add = 0, del = 0;
|
|
166
|
+
for (var i = 0; i < changes.length; i++) {
|
|
167
|
+
var c = changes[i];
|
|
168
|
+
if (c.modifiedEndLineNumber >= c.modifiedStartLineNumber && c.modifiedEndLineNumber > 0) {
|
|
169
|
+
add += c.modifiedEndLineNumber - c.modifiedStartLineNumber + 1;
|
|
170
|
+
}
|
|
171
|
+
if (c.originalEndLineNumber >= c.originalStartLineNumber && c.originalEndLineNumber > 0) {
|
|
172
|
+
del += c.originalEndLineNumber - c.originalStartLineNumber + 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
statEl.textContent = "+" + add + " −" + del;
|
|
176
|
+
});
|
|
177
|
+
var me = diffEditor.getModifiedEditor();
|
|
178
|
+
me.onDidChangeCursorPosition(function (e) {
|
|
179
|
+
caretEl.textContent = "行 " + e.position.lineNumber + "、列 " + e.position.column;
|
|
180
|
+
});
|
|
181
|
+
me.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, doSave);
|
|
182
|
+
saveBtn.hidden = false;
|
|
183
|
+
saveBtn.addEventListener("click", doSave);
|
|
184
|
+
document.addEventListener("keydown", function (e) {
|
|
185
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "s" || e.key === "S")) {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
doSave();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
window.addEventListener("beforeunload", function (e) {
|
|
191
|
+
if (dirty) e.preventDefault();
|
|
192
|
+
});
|
|
193
|
+
clearTimeout(watchdog);
|
|
194
|
+
setMsg("", "");
|
|
195
|
+
me.focus();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function doSave() {
|
|
199
|
+
if (saving || modified === null) return;
|
|
200
|
+
saving = true;
|
|
201
|
+
setMsg("保存中…", "");
|
|
202
|
+
var body = new URLSearchParams();
|
|
203
|
+
body.set("baseSha256", baseSha);
|
|
204
|
+
body.set("text", modified.getValue());
|
|
205
|
+
body.set("ajax", "1");
|
|
206
|
+
fetch(window.location.pathname, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { "content-type": "application/x-www-form-urlencoded;charset=UTF-8" },
|
|
209
|
+
body: body.toString(),
|
|
210
|
+
}).then(function (res) {
|
|
211
|
+
return res.json().then(function (j) { return { status: res.status, j: j }; });
|
|
212
|
+
}).then(function (r) {
|
|
213
|
+
saving = false;
|
|
214
|
+
if (r.j.ok === true) {
|
|
215
|
+
baseSha = r.j.baseSha256;
|
|
216
|
+
dirty = false;
|
|
217
|
+
dirtyEl.style.visibility = "hidden";
|
|
218
|
+
setMsg("保存しました ✓", "ok");
|
|
219
|
+
if (msgTimer !== null) clearTimeout(msgTimer);
|
|
220
|
+
msgTimer = setTimeout(function () { setMsg("", ""); }, 3000);
|
|
221
|
+
} else {
|
|
222
|
+
setMsg(r.j.error || "保存に失敗しました (" + r.status + ")", "err");
|
|
223
|
+
}
|
|
224
|
+
}).catch(function () {
|
|
225
|
+
saving = false;
|
|
226
|
+
setMsg("保存に失敗しました (接続エラー)", "err");
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
`;
|
|
231
|
+
export function buildEditorPage(d) {
|
|
232
|
+
const banners = [];
|
|
233
|
+
if (d.saved)
|
|
234
|
+
banners.push(`<div class="banner ok">保存しました</div>`);
|
|
235
|
+
if (d.errorBanner !== null)
|
|
236
|
+
banners.push(`<div class="banner err">${escapeHtml(d.errorBanner)}</div>`);
|
|
237
|
+
if (d.blockReason !== null)
|
|
238
|
+
banners.push(`<div class="banner err">${escapeHtml(d.blockReason)}</div>`);
|
|
239
|
+
if (d.snapshotNote !== null)
|
|
240
|
+
banners.push(`<div class="banner">${escapeHtml(d.snapshotNote)}</div>`);
|
|
241
|
+
// JS 無効時のフォールバック: 素のフォーム POST (§8 の保存経路は同じ)。
|
|
242
|
+
// <textarea> 開きタグ直後の改行はブラウザに 1 個食われるため必ず入れる
|
|
243
|
+
const noscriptForm = d.blockReason !== null
|
|
244
|
+
? ""
|
|
245
|
+
: `<noscript><div class="nsform">
|
|
246
|
+
<p class="note">JS が無効のため Monaco エディタは使えません。以下で直接編集して保存できます (${escapeHtml(d.leftLabel)}との差分表示はありません)</p>
|
|
247
|
+
<form method="post" action="">
|
|
248
|
+
<input type="hidden" name="baseSha256" value="${escapeHtml(d.baseSha256)}">
|
|
249
|
+
<textarea name="text" spellcheck="false" autocomplete="off">
|
|
250
|
+
${escapeHtml(d.currentText)}</textarea>
|
|
251
|
+
<p><button class="btn">保存</button></p>
|
|
252
|
+
</form>
|
|
253
|
+
</div></noscript>`;
|
|
254
|
+
const editorArea = d.blockReason !== null
|
|
255
|
+
? `<div class="note">${escapeHtml(d.blockReason)}</div>`
|
|
256
|
+
: `<div id="ed"></div>\n${noscriptForm}`;
|
|
257
|
+
const data = {
|
|
258
|
+
blocked: d.blockReason !== null,
|
|
259
|
+
path: d.path,
|
|
260
|
+
snapshotText: d.snapshotText,
|
|
261
|
+
currentText: d.currentText,
|
|
262
|
+
baseSha256: d.baseSha256,
|
|
263
|
+
};
|
|
264
|
+
const body = `<div class="app">
|
|
265
|
+
<div class="titlebar">
|
|
266
|
+
<div class="tab"><span id="dirty">●</span><code>${escapeHtml(d.path)}</code></div>
|
|
267
|
+
<span id="msg">エディタを読み込み中…</span>
|
|
268
|
+
<div class="tb-sp"></div>
|
|
269
|
+
${d.blockReason === null ? `<button class="btn" id="savebtn" hidden>保存</button>` : ""}
|
|
270
|
+
<a class="btn ghost" href="../">一覧</a>
|
|
271
|
+
<form class="inline" method="post" action="../quit"><button class="btn ghost">終了</button></form>
|
|
272
|
+
</div>
|
|
273
|
+
${banners.join("\n")}
|
|
274
|
+
${editorArea}
|
|
275
|
+
<div class="statusbar">
|
|
276
|
+
<span>${escapeHtml(d.leftLabel)} ⇔ 現在のファイル</span>
|
|
277
|
+
<span id="diffstat"></span>
|
|
278
|
+
<span class="sp"></span>
|
|
279
|
+
<span>無操作 ${d.idleMinutes} 分で自動終了</span>
|
|
280
|
+
<span>${escapeHtml(d.encodingLabel)}</span>
|
|
281
|
+
<span>${escapeHtml(d.eolLabel)}</span>
|
|
282
|
+
<span id="caret"></span>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
<script type="application/json" id="pd">${embedJson(data)}</script>
|
|
286
|
+
<script nonce="${escapeHtml(d.nonce)}">${BOOT_JS}</script>`;
|
|
287
|
+
return `<!doctype html>
|
|
288
|
+
<html lang="ja">
|
|
289
|
+
<head>
|
|
290
|
+
<meta charset="utf-8">
|
|
291
|
+
<meta http-equiv="Content-Security-Policy" content="${editorCsp(d.nonce)}">
|
|
292
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
293
|
+
<title>${escapeHtml(d.path)} — petari</title>
|
|
294
|
+
<style>${STYLE}</style>
|
|
295
|
+
</head>
|
|
296
|
+
<body>
|
|
297
|
+
${body}
|
|
298
|
+
</body>
|
|
299
|
+
</html>
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS 既定ブラウザで URL / ファイルを開く (§4.3 ブラウザ差分ビュー)。
|
|
3
|
+
* 依存ゼロ方針のため OS コマンドを呼び出す (常に execFile の配列引数・シェル非経由)。
|
|
4
|
+
* 対象は自前で生成した URL (http://127.0.0.1:...) か一時ファイルのパスのみで
|
|
5
|
+
* 外部入力は渡さないが、Windows の引用は clipboard.ts と同じ単一引用符方式で行う。
|
|
6
|
+
* 自動テストなし (実機確認のみ — clipboard.ts と同方針)。
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const execFileP = promisify(execFile);
|
|
11
|
+
/** 既定ブラウザで開く。失敗時は throw (呼び出し側がパス/URL を案内して続行する) */
|
|
12
|
+
export async function openInBrowser(target) {
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
await execFileP("open", [target]);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (process.platform === "win32") {
|
|
18
|
+
// ' は '' にエスケープした単一引用符リテラルで渡す (展開・注入が起きない)
|
|
19
|
+
const quoted = `'${target.replaceAll("'", "''")}'`;
|
|
20
|
+
await execFileP("powershell", ["-NoProfile", "-Command", `Start-Process ${quoted}`]);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await execFileP("xdg-open", [target]);
|
|
24
|
+
}
|