dsh-linux-commander 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +100 -2
- package/build-client.mjs +59 -0
- package/client.js +427 -0
- package/cordis.patch.yml +12 -0
- package/icon.svg +6 -0
- package/index.js +82 -0
- package/locale/en.json +21 -0
- package/locale/zh.json +21 -0
- package/package.json +41 -3
- package/src/ai.js +190 -0
- package/src/client/widget.js +393 -0
- package/src/commands.js +349 -0
- package/src/core.js +244 -0
- package/src/nl.js +268 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Linux 指令器 — client widget source (plain JS, inlined into client.js by
|
|
2
|
+
// build-client.mjs). Expects React, ctx, t in scope via createWidget args.
|
|
3
|
+
// Minimal floating tool: right-edge dock tab -> compact query panel.
|
|
4
|
+
// Never executes anything; commands are text only.
|
|
5
|
+
|
|
6
|
+
const CSS = String.raw`
|
|
7
|
+
.lxc-root{position:fixed;right:0;top:0;width:0;height:0;z-index:1000;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;}
|
|
8
|
+
.lxc-root *,.lxc-root *::before,.lxc-root *::after{box-sizing:border-box;}
|
|
9
|
+
.lxc-dock{position:absolute;right:0;margin-top:-28px;width:30px;height:56px;display:flex;align-items:center;justify-content:center;padding:0;border:1px solid var(--dsw-alias-border-l1);border-right:none;border-radius:10px 0 0 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;box-shadow:-2px 2px 10px color-mix(in srgb,var(--dsw-alias-label-primary) 10%,transparent);transition:color .16s ease,background .16s ease,transform .16s ease,box-shadow .16s ease;animation:lxc-fade .18s ease;}
|
|
10
|
+
.lxc-dock:hover{color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);transform:translateX(-2px);}
|
|
11
|
+
.lxc-dock:active{transform:translateX(-2px) scale(.96);}
|
|
12
|
+
.lxc-dock.lxc-dragging{cursor:grabbing;transition:none;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2);transform:translateX(-2px) scale(1.04);box-shadow:-4px 5px 16px color-mix(in srgb,var(--dsw-alias-label-primary) 20%,transparent);}
|
|
13
|
+
.lxc-dock:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}
|
|
14
|
+
.lxc-panel{position:absolute;right:10px;width:min(340px,calc(100vw - 24px));max-height:min(460px,calc(100vh - 32px));display:flex;flex-direction:column;background:var(--dsw-alias-bg-overlay);border:1px solid var(--dsw-alias-border-l1);border-radius:14px;box-shadow:0 12px 32px color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent),0 2px 8px color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);animation:lxc-in .19s cubic-bezier(.2,.7,.3,1);}
|
|
15
|
+
.lxc-panel.lxc-closing{animation:lxc-out .15s ease forwards;}
|
|
16
|
+
.lxc-head{display:flex;align-items:center;justify-content:space-between;padding:10px 10px 8px 14px;flex:none;}
|
|
17
|
+
.lxc-title{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-primary);user-select:none;}
|
|
18
|
+
.lxc-close{width:24px;height:24px;display:flex;align-items:center;justify-content:center;padding:0;border:none;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:background .14s ease,color .14s ease;}
|
|
19
|
+
.lxc-close:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);}
|
|
20
|
+
.lxc-close:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}
|
|
21
|
+
.lxc-form{padding:0 12px;flex:none;}
|
|
22
|
+
.lxc-input{width:100%;height:36px;padding:0 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit;outline:none;transition:border-color .14s ease,box-shadow .14s ease;}
|
|
23
|
+
.lxc-input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.75;}
|
|
24
|
+
.lxc-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent);}
|
|
25
|
+
.lxc-input.lxc-shake{animation:lxc-shake .24s ease;}
|
|
26
|
+
.lxc-result{margin:10px 12px 12px;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2) transparent;}
|
|
27
|
+
.lxc-result::-webkit-scrollbar{width:6px;}
|
|
28
|
+
.lxc-result::-webkit-scrollbar-track{background:transparent;}
|
|
29
|
+
.lxc-result::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2);border-radius:3px;}
|
|
30
|
+
.lxc-result::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb,var(--dsw-alias-label-secondary) 55%,transparent);}
|
|
31
|
+
.lxc-cmd{position:relative;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-base);}
|
|
32
|
+
.lxc-cmd code{display:block;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12.5px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-all;user-select:text;}
|
|
33
|
+
.lxc-foot{display:flex;align-items:center;justify-content:space-between;margin-top:7px;}
|
|
34
|
+
.lxc-copy{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-family:inherit;cursor:pointer;transition:color .14s ease,border-color .14s ease,background .14s ease;}
|
|
35
|
+
.lxc-copy:hover{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);}
|
|
36
|
+
.lxc-copy:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}
|
|
37
|
+
.lxc-copy.lxc-ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);}
|
|
38
|
+
.lxc-copy.lxc-fail{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);}
|
|
39
|
+
.lxc-src{font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.8;user-select:none;letter-spacing:.04em;}
|
|
40
|
+
.lxc-note{margin-top:8px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);}
|
|
41
|
+
.lxc-warn{display:flex;align-items:flex-start;gap:6px;margin-top:8px;padding:6px 8px;border-radius:7px;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);color:var(--dsw-alias-state-warn-primary);font-size:11.5px;line-height:1.5;}
|
|
42
|
+
.lxc-warn svg{flex:none;margin-top:1px;}
|
|
43
|
+
.lxc-err{display:flex;align-items:center;gap:6px;padding:6px 2px;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5;}
|
|
44
|
+
.lxc-loading{display:flex;align-items:center;justify-content:center;gap:5px;height:44px;}
|
|
45
|
+
.lxc-loading i{width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-label-secondary);opacity:.35;animation:lxc-pulse 1s ease-in-out infinite;}
|
|
46
|
+
.lxc-loading i:nth-child(2){animation-delay:.16s;}
|
|
47
|
+
.lxc-loading i:nth-child(3){animation-delay:.32s;}
|
|
48
|
+
@keyframes lxc-in{from{opacity:0;transform:translateX(12px);}to{opacity:1;transform:translateX(0);}}
|
|
49
|
+
@keyframes lxc-out{from{opacity:1;transform:translateX(0);}to{opacity:0;transform:translateX(12px);}}
|
|
50
|
+
@keyframes lxc-fade{from{opacity:0;}to{opacity:1;}}
|
|
51
|
+
@keyframes lxc-pulse{0%,100%{opacity:.25;transform:scale(.85);}50%{opacity:.9;transform:scale(1);}}
|
|
52
|
+
@keyframes lxc-shake{0%,100%{transform:translateX(0);}25%{transform:translateX(-3px);}75%{transform:translateX(3px);}}
|
|
53
|
+
@media (max-width:480px){
|
|
54
|
+
.lxc-panel{right:8px;width:calc(100vw - 16px);}
|
|
55
|
+
.lxc-dock{width:26px;height:48px;margin-top:-24px;}
|
|
56
|
+
}
|
|
57
|
+
@media (prefers-reduced-motion:reduce){
|
|
58
|
+
.lxc-panel,.lxc-dock,.lxc-input,.lxc-copy,.lxc-close,.lxc-loading i{animation:none !important;transition:none !important;}
|
|
59
|
+
.lxc-panel.lxc-closing{opacity:0;}
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const svgProps = { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true };
|
|
64
|
+
|
|
65
|
+
function DockIcon() {
|
|
66
|
+
return React.createElement('svg', { ...svgProps, width: 15, height: 15 },
|
|
67
|
+
React.createElement('path', { d: 'M3 4.5 L6.5 8 L3 11.5', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' }),
|
|
68
|
+
React.createElement('line', { x1: 8.2, y1: 11.8, x2: 13, y2: 11.8, stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' }));
|
|
69
|
+
}
|
|
70
|
+
function CloseIcon() {
|
|
71
|
+
return React.createElement('svg', { ...svgProps, width: 13, height: 13 },
|
|
72
|
+
React.createElement('path', { d: 'M3.5 3.5 L12.5 12.5 M12.5 3.5 L3.5 12.5', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }));
|
|
73
|
+
}
|
|
74
|
+
function CopyIcon() {
|
|
75
|
+
return React.createElement('svg', { ...svgProps, width: 12, height: 12 },
|
|
76
|
+
React.createElement('rect', { x: 5, y: 5, width: 8.5, height: 8.5, rx: 1.8, stroke: 'currentColor', strokeWidth: 1.4 }),
|
|
77
|
+
React.createElement('path', { d: 'M11 3.2 V2.8 A1.8 1.8 0 0 0 9.2 1 H2.8 A1.8 1.8 0 0 0 1 2.8 V9.2 A1.8 1.8 0 0 0 2.8 11 H3.2', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }));
|
|
78
|
+
}
|
|
79
|
+
function CheckIcon() {
|
|
80
|
+
return React.createElement('svg', { ...svgProps, width: 12, height: 12 },
|
|
81
|
+
React.createElement('path', { d: 'M2.5 8.5 L6 12 L13.5 4', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' }));
|
|
82
|
+
}
|
|
83
|
+
function WarnIcon() {
|
|
84
|
+
return React.createElement('svg', { ...svgProps, width: 12, height: 12 },
|
|
85
|
+
React.createElement('path', { d: 'M8 1.8 L15 13.6 H1 Z', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round' }),
|
|
86
|
+
React.createElement('line', { x1: 8, y1: 6.2, x2: 8, y2: 9.2, stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }),
|
|
87
|
+
React.createElement('circle', { cx: 8, cy: 11.4, r: .9, fill: 'currentColor' }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createWidget(React, ctx, t) {
|
|
91
|
+
const h = React.createElement;
|
|
92
|
+
const { useState, useRef, useEffect, useLayoutEffect, useReducer } = React;
|
|
93
|
+
|
|
94
|
+
const DOCK_MARGIN = 36; // keep the tab fully on-screen
|
|
95
|
+
const STORE_KEY = 'linux-commander.dockY';
|
|
96
|
+
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
|
|
97
|
+
|
|
98
|
+
function initialDockY() {
|
|
99
|
+
const vh = window.innerHeight || 800;
|
|
100
|
+
let frac = 0.5;
|
|
101
|
+
try {
|
|
102
|
+
const saved = Number(localStorage.getItem(STORE_KEY));
|
|
103
|
+
if (Number.isFinite(saved) && saved > 0 && saved < 1) frac = saved;
|
|
104
|
+
} catch { /* storage unavailable */ }
|
|
105
|
+
return clamp(frac * vh, DOCK_MARGIN, vh - DOCK_MARGIN);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function Widget() {
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
const [closing, setClosing] = useState(false);
|
|
111
|
+
const [value, setValue] = useState('');
|
|
112
|
+
const [phase, setPhase] = useState('idle'); // idle | loading | done | error
|
|
113
|
+
const [result, setResult] = useState(null);
|
|
114
|
+
const [error, setError] = useState('');
|
|
115
|
+
const [copyState, setCopyState] = useState('idle'); // idle | ok | fail
|
|
116
|
+
const [shake, setShake] = useState(false);
|
|
117
|
+
const [dockY, setDockYState] = useState(initialDockY);
|
|
118
|
+
const [vh, setVh] = useState(() => window.innerHeight || 800);
|
|
119
|
+
const [panelH, setPanelH] = useState(180);
|
|
120
|
+
const [dragging, setDragging] = useState(false);
|
|
121
|
+
const [, bump] = useReducer((x) => x + 1, 0);
|
|
122
|
+
|
|
123
|
+
const inputRef = useRef(null);
|
|
124
|
+
const dockRef = useRef(null);
|
|
125
|
+
const panelRef = useRef(null);
|
|
126
|
+
const seqRef = useRef(0);
|
|
127
|
+
const abortRef = useRef(null);
|
|
128
|
+
const lastQueryRef = useRef('');
|
|
129
|
+
const closeTimerRef = useRef(null);
|
|
130
|
+
const copyTimerRef = useRef(null);
|
|
131
|
+
const shakeTimerRef = useRef(null);
|
|
132
|
+
const closingRef = useRef(false);
|
|
133
|
+
const dockYRef = useRef(dockY);
|
|
134
|
+
const dragRef = useRef(null);
|
|
135
|
+
const suppressClickRef = useRef(false);
|
|
136
|
+
|
|
137
|
+
function setDockY(y) {
|
|
138
|
+
dockYRef.current = y;
|
|
139
|
+
setDockYState(y);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function persistDockY() {
|
|
143
|
+
try {
|
|
144
|
+
const frac = dockYRef.current / (window.innerHeight || vh || 1);
|
|
145
|
+
localStorage.setItem(STORE_KEY, String(clamp(frac, 0, 1)));
|
|
146
|
+
} catch { /* storage unavailable */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
useEffect(() => ctx.locale.subscribe(bump), [ctx]);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const onResize = () => {
|
|
153
|
+
const hgt = window.innerHeight || 800;
|
|
154
|
+
setVh(hgt);
|
|
155
|
+
setDockY(clamp(dockYRef.current, DOCK_MARGIN, hgt - DOCK_MARGIN));
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener('resize', onResize);
|
|
158
|
+
return () => window.removeEventListener('resize', onResize);
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// Measure the panel so it stays fully on-screen near the dock position.
|
|
162
|
+
useLayoutEffect(() => {
|
|
163
|
+
if (!open || !panelRef.current) return;
|
|
164
|
+
const el = panelRef.current;
|
|
165
|
+
const measure = () => setPanelH(el.offsetHeight || 180);
|
|
166
|
+
measure();
|
|
167
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
168
|
+
const ro = new ResizeObserver(measure);
|
|
169
|
+
ro.observe(el);
|
|
170
|
+
return () => ro.disconnect();
|
|
171
|
+
}, [open]);
|
|
172
|
+
|
|
173
|
+
useEffect(() => () => {
|
|
174
|
+
abortRef.current?.abort();
|
|
175
|
+
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
176
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
|
177
|
+
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!open) return;
|
|
182
|
+
const raf = requestAnimationFrame(() => {
|
|
183
|
+
const el = inputRef.current;
|
|
184
|
+
if (el) { el.focus(); el.select(); }
|
|
185
|
+
});
|
|
186
|
+
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); closePanel(); } };
|
|
187
|
+
document.addEventListener('keydown', onKey, true);
|
|
188
|
+
return () => { cancelAnimationFrame(raf); document.removeEventListener('keydown', onKey, true); };
|
|
189
|
+
}, [open]);
|
|
190
|
+
|
|
191
|
+
// --- vertical drag of the docked tab (pointer + keyboard) ---
|
|
192
|
+
function onDockPointerDown(e) {
|
|
193
|
+
if (e.button !== undefined && e.button !== 0) return;
|
|
194
|
+
dragRef.current = { startY: e.clientY, origY: dockYRef.current, moved: false };
|
|
195
|
+
try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* older browsers */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function onDockPointerMove(e) {
|
|
199
|
+
const d = dragRef.current;
|
|
200
|
+
if (!d) return;
|
|
201
|
+
const dy = e.clientY - d.startY;
|
|
202
|
+
if (!d.moved && Math.abs(dy) > 4) { d.moved = true; setDragging(true); }
|
|
203
|
+
if (d.moved) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
setDockY(clamp(d.origY + dy, DOCK_MARGIN, (window.innerHeight || vh) - DOCK_MARGIN));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function onDockPointerUp(e) {
|
|
210
|
+
const d = dragRef.current;
|
|
211
|
+
dragRef.current = null;
|
|
212
|
+
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* already released */ }
|
|
213
|
+
if (!d) return;
|
|
214
|
+
if (d.moved) {
|
|
215
|
+
setDragging(false);
|
|
216
|
+
persistDockY();
|
|
217
|
+
suppressClickRef.current = true; // the trailing click must not open the panel
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function onDockPointerCancel() {
|
|
222
|
+
dragRef.current = null;
|
|
223
|
+
setDragging(false);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onDockClick() {
|
|
227
|
+
if (suppressClickRef.current) { suppressClickRef.current = false; return; }
|
|
228
|
+
openPanel();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onDockKeyDown(e) {
|
|
232
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
const step = e.shiftKey ? 96 : 24;
|
|
235
|
+
const dy = e.key === 'ArrowUp' ? -step : step;
|
|
236
|
+
setDockY(clamp(dockYRef.current + dy, DOCK_MARGIN, (window.innerHeight || vh) - DOCK_MARGIN));
|
|
237
|
+
persistDockY();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function openPanel() {
|
|
241
|
+
if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; }
|
|
242
|
+
closingRef.current = false;
|
|
243
|
+
setClosing(false);
|
|
244
|
+
setOpen(true);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function closePanel() {
|
|
248
|
+
if (closingRef.current) return;
|
|
249
|
+
closingRef.current = true;
|
|
250
|
+
abortRef.current?.abort();
|
|
251
|
+
seqRef.current++;
|
|
252
|
+
setPhase((p) => (p === 'loading' ? 'idle' : p));
|
|
253
|
+
setClosing(true);
|
|
254
|
+
closeTimerRef.current = setTimeout(() => {
|
|
255
|
+
closeTimerRef.current = null;
|
|
256
|
+
closingRef.current = false;
|
|
257
|
+
setClosing(false);
|
|
258
|
+
setOpen(false);
|
|
259
|
+
requestAnimationFrame(() => dockRef.current?.focus());
|
|
260
|
+
}, 155);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function submit(e) {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
if (e.nativeEvent && e.nativeEvent.repeat) return;
|
|
266
|
+
const q = value.replace(/[\r\n]+/g, ' ').trim();
|
|
267
|
+
if (!q) {
|
|
268
|
+
setShake(true);
|
|
269
|
+
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
|
|
270
|
+
shakeTimerRef.current = setTimeout(() => setShake(false), 260);
|
|
271
|
+
inputRef.current?.focus();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (phase === 'loading' && q === lastQueryRef.current) return;
|
|
275
|
+
const seq = ++seqRef.current;
|
|
276
|
+
abortRef.current?.abort();
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
abortRef.current = controller;
|
|
279
|
+
lastQueryRef.current = q;
|
|
280
|
+
setPhase('loading');
|
|
281
|
+
setResult(null);
|
|
282
|
+
setError('');
|
|
283
|
+
setCopyState('idle');
|
|
284
|
+
fetch('/linux-commander/query', {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({ input: q }),
|
|
288
|
+
signal: controller.signal,
|
|
289
|
+
})
|
|
290
|
+
.then(async (res) => {
|
|
291
|
+
let data = null;
|
|
292
|
+
try { data = await res.json(); } catch { /* non-JSON */ }
|
|
293
|
+
if (seq !== seqRef.current) return;
|
|
294
|
+
if (data && data.ok) { setResult(data); setPhase('done'); }
|
|
295
|
+
else { setError((data && data.message) || t('errorGeneric')); setPhase('error'); }
|
|
296
|
+
})
|
|
297
|
+
.catch(() => {
|
|
298
|
+
if (seq !== seqRef.current || controller.signal.aborted) return;
|
|
299
|
+
setError(t('errorNetwork'));
|
|
300
|
+
setPhase('error');
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function copyCommand() {
|
|
305
|
+
const text = result?.command;
|
|
306
|
+
if (!text) return;
|
|
307
|
+
let ok = false;
|
|
308
|
+
try {
|
|
309
|
+
await navigator.clipboard.writeText(text);
|
|
310
|
+
ok = true;
|
|
311
|
+
} catch {
|
|
312
|
+
try {
|
|
313
|
+
const ta = document.createElement('textarea');
|
|
314
|
+
ta.value = text;
|
|
315
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
|
|
316
|
+
document.body.appendChild(ta);
|
|
317
|
+
ta.select();
|
|
318
|
+
ok = document.execCommand('copy');
|
|
319
|
+
ta.remove();
|
|
320
|
+
} catch { ok = false; }
|
|
321
|
+
}
|
|
322
|
+
setCopyState(ok ? 'ok' : 'fail');
|
|
323
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
|
324
|
+
copyTimerRef.current = setTimeout(() => setCopyState('idle'), 1400);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!open) {
|
|
328
|
+
return h('div', { className: 'lxc-root' },
|
|
329
|
+
h('button', {
|
|
330
|
+
ref: dockRef, type: 'button',
|
|
331
|
+
className: 'lxc-dock' + (dragging ? ' lxc-dragging' : ''),
|
|
332
|
+
style: { top: dockY + 'px' },
|
|
333
|
+
'aria-label': t('open'),
|
|
334
|
+
onClick: onDockClick,
|
|
335
|
+
onPointerDown: onDockPointerDown,
|
|
336
|
+
onPointerMove: onDockPointerMove,
|
|
337
|
+
onPointerUp: onDockPointerUp,
|
|
338
|
+
onPointerCancel: onDockPointerCancel,
|
|
339
|
+
onKeyDown: onDockKeyDown,
|
|
340
|
+
}, h(DockIcon)));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const panelTop = clamp(dockY - panelH / 2, 8, Math.max(8, vh - panelH - 8));
|
|
344
|
+
|
|
345
|
+
const copyLabel = copyState === 'ok' ? t('copied') : copyState === 'fail' ? t('copyFailed') : t('copy');
|
|
346
|
+
const sourceLabel = result ? (result.source === 'local' ? t('sourceLocal') : t('sourceAi')) : '';
|
|
347
|
+
|
|
348
|
+
let body = null;
|
|
349
|
+
if (phase === 'loading') {
|
|
350
|
+
body = h('div', { className: 'lxc-loading', role: 'status', 'aria-label': t('loading') },
|
|
351
|
+
h('i'), h('i'), h('i'));
|
|
352
|
+
} else if (phase === 'error') {
|
|
353
|
+
body = h('div', { className: 'lxc-err', role: 'alert' }, error);
|
|
354
|
+
} else if (phase === 'done' && result) {
|
|
355
|
+
body = h(React.Fragment, null,
|
|
356
|
+
h('div', { className: 'lxc-cmd' }, h('code', null, result.command)),
|
|
357
|
+
h('div', { className: 'lxc-foot' },
|
|
358
|
+
h('button', {
|
|
359
|
+
type: 'button',
|
|
360
|
+
className: 'lxc-copy' + (copyState === 'ok' ? ' lxc-ok' : copyState === 'fail' ? ' lxc-fail' : ''),
|
|
361
|
+
onClick: copyCommand, 'aria-live': 'polite',
|
|
362
|
+
}, copyState === 'ok' ? h(CheckIcon) : h(CopyIcon), copyLabel),
|
|
363
|
+
sourceLabel ? h('span', { className: 'lxc-src' }, sourceLabel) : null),
|
|
364
|
+
result.explanation ? h('div', { className: 'lxc-note' }, result.explanation) : null,
|
|
365
|
+
result.warning ? h('div', { className: 'lxc-warn' }, h(WarnIcon), h('span', null, result.warning)) : null);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return h('div', { className: 'lxc-root' },
|
|
369
|
+
h('div', {
|
|
370
|
+
ref: panelRef,
|
|
371
|
+
className: 'lxc-panel' + (closing ? ' lxc-closing' : ''),
|
|
372
|
+
style: { top: panelTop + 'px' },
|
|
373
|
+
role: 'dialog', 'aria-label': t('title'),
|
|
374
|
+
},
|
|
375
|
+
h('div', { className: 'lxc-head' },
|
|
376
|
+
h('span', { className: 'lxc-title' }, t('title')),
|
|
377
|
+
h('button', { type: 'button', className: 'lxc-close', 'aria-label': t('close'), onClick: closePanel }, h(CloseIcon))),
|
|
378
|
+
h('form', { className: 'lxc-form', onSubmit: submit },
|
|
379
|
+
h('input', {
|
|
380
|
+
ref: inputRef,
|
|
381
|
+
className: 'lxc-input' + (shake ? ' lxc-shake' : ''),
|
|
382
|
+
type: 'text', value, placeholder: t('placeholder'),
|
|
383
|
+
'aria-label': t('placeholder'),
|
|
384
|
+
autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false,
|
|
385
|
+
enterKeyHint: 'search',
|
|
386
|
+
onChange: (e) => setValue(e.target.value),
|
|
387
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && e.repeat) e.preventDefault(); },
|
|
388
|
+
})),
|
|
389
|
+
h('div', { className: 'lxc-result', 'aria-live': 'polite' }, body)));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return Widget;
|
|
393
|
+
}
|