@fresh-editor/fresh-editor 0.1.10 → 0.1.12
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/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/plugins/calculator.ts +768 -0
- package/plugins/lib/fresh.d.ts +17 -2
- package/plugins/live_grep.ts +342 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"hasInstallScript": true,
|
|
24
24
|
"license": "GPL-2.0",
|
|
25
25
|
"name": "@fresh-editor/fresh-editor",
|
|
26
|
-
"version": "0.1.
|
|
26
|
+
"version": "0.1.12"
|
|
27
27
|
},
|
|
28
28
|
"node_modules/@isaacs/balanced-match": {
|
|
29
29
|
"engines": {
|
|
@@ -896,5 +896,5 @@
|
|
|
896
896
|
}
|
|
897
897
|
},
|
|
898
898
|
"requires": true,
|
|
899
|
-
"version": "0.1.
|
|
899
|
+
"version": "0.1.12"
|
|
900
900
|
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"artifactDownloadUrl": "https://github.com/sinelaw/fresh/releases/download/v0.1.
|
|
2
|
+
"artifactDownloadUrl": "https://github.com/sinelaw/fresh/releases/download/v0.1.12",
|
|
3
3
|
"author": "Noam Lewis",
|
|
4
4
|
"bin": {
|
|
5
5
|
"fresh": "run-fresh.js"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"zipExt": ".tar.xz"
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
|
-
"version": "0.1.
|
|
95
|
+
"version": "0.1.12",
|
|
96
96
|
"volta": {
|
|
97
97
|
"node": "18.14.1",
|
|
98
98
|
"npm": "9.5.0"
|
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
/// <reference path="../types/fresh.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculator Plugin for Fresh Editor
|
|
5
|
+
*
|
|
6
|
+
* A sleek visual calculator with:
|
|
7
|
+
* - Mouse-clickable buttons (anywhere in button area)
|
|
8
|
+
* - Keyboard input support
|
|
9
|
+
* - Expression parsing with parentheses and basic arithmetic
|
|
10
|
+
* - Modern calculator styling with ANSI colors
|
|
11
|
+
* - Compact fixed-size layout centered in view
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ANSI color codes
|
|
15
|
+
const C = {
|
|
16
|
+
RESET: "\x1b[0m",
|
|
17
|
+
BOLD: "\x1b[1m",
|
|
18
|
+
DIM: "\x1b[2m",
|
|
19
|
+
// Colors
|
|
20
|
+
RED: "\x1b[31m",
|
|
21
|
+
GREEN: "\x1b[32m",
|
|
22
|
+
YELLOW: "\x1b[33m",
|
|
23
|
+
BLUE: "\x1b[34m",
|
|
24
|
+
MAGENTA: "\x1b[35m",
|
|
25
|
+
CYAN: "\x1b[36m",
|
|
26
|
+
WHITE: "\x1b[37m",
|
|
27
|
+
BRIGHT_RED: "\x1b[91m",
|
|
28
|
+
BRIGHT_GREEN: "\x1b[92m",
|
|
29
|
+
BRIGHT_YELLOW: "\x1b[93m",
|
|
30
|
+
BRIGHT_BLUE: "\x1b[94m",
|
|
31
|
+
BRIGHT_MAGENTA: "\x1b[95m",
|
|
32
|
+
BRIGHT_CYAN: "\x1b[96m",
|
|
33
|
+
// Backgrounds
|
|
34
|
+
BG_BLACK: "\x1b[40m",
|
|
35
|
+
BG_RED: "\x1b[41m",
|
|
36
|
+
BG_GREEN: "\x1b[42m",
|
|
37
|
+
BG_YELLOW: "\x1b[43m",
|
|
38
|
+
BG_BLUE: "\x1b[44m",
|
|
39
|
+
BG_MAGENTA: "\x1b[45m",
|
|
40
|
+
BG_CYAN: "\x1b[46m",
|
|
41
|
+
BG_WHITE: "\x1b[47m",
|
|
42
|
+
BG_BRIGHT_BLACK: "\x1b[100m",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Calculator state
|
|
46
|
+
interface CalculatorState {
|
|
47
|
+
expression: string;
|
|
48
|
+
result: string;
|
|
49
|
+
error: string;
|
|
50
|
+
bufferId: number;
|
|
51
|
+
splitId: number;
|
|
52
|
+
lastViewport: ViewportInfo | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const state: CalculatorState = {
|
|
56
|
+
expression: "",
|
|
57
|
+
result: "0",
|
|
58
|
+
error: "",
|
|
59
|
+
bufferId: 0,
|
|
60
|
+
splitId: 0,
|
|
61
|
+
lastViewport: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Cache the layout so it doesn't jump around
|
|
65
|
+
let cachedLayout: LayoutMetrics | null = null;
|
|
66
|
+
|
|
67
|
+
// Track hovered button for visual feedback
|
|
68
|
+
let hoveredButton: { row: number; col: number } | null = null;
|
|
69
|
+
|
|
70
|
+
// Track if copy button is hovered
|
|
71
|
+
let copyButtonHovered = false;
|
|
72
|
+
|
|
73
|
+
// Button definitions
|
|
74
|
+
interface Button {
|
|
75
|
+
label: string;
|
|
76
|
+
action: string;
|
|
77
|
+
type: "number" | "operator" | "function" | "clear" | "equals";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const BUTTON_LAYOUT: Button[][] = [
|
|
81
|
+
[
|
|
82
|
+
{ label: "C", action: "clear", type: "clear" },
|
|
83
|
+
{ label: "(", action: "(", type: "function" },
|
|
84
|
+
{ label: ")", action: ")", type: "function" },
|
|
85
|
+
{ label: "^", action: "^", type: "operator" },
|
|
86
|
+
{ label: "÷", action: "/", type: "operator" },
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
{ label: "sqrt", action: "sqrt(", type: "function" },
|
|
90
|
+
{ label: "ln", action: "ln(", type: "function" },
|
|
91
|
+
{ label: "log", action: "log(", type: "function" },
|
|
92
|
+
{ label: "π", action: "pi", type: "number" },
|
|
93
|
+
{ label: "×", action: "*", type: "operator" },
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
{ label: "sin", action: "sin(", type: "function" },
|
|
97
|
+
{ label: "cos", action: "cos(", type: "function" },
|
|
98
|
+
{ label: "tan", action: "tan(", type: "function" },
|
|
99
|
+
{ label: "e", action: "e", type: "number" },
|
|
100
|
+
{ label: "-", action: "-", type: "operator" },
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
{ label: "7", action: "7", type: "number" },
|
|
104
|
+
{ label: "8", action: "8", type: "number" },
|
|
105
|
+
{ label: "9", action: "9", type: "number" },
|
|
106
|
+
{ label: "⌫", action: "backspace", type: "clear" },
|
|
107
|
+
{ label: "+", action: "+", type: "operator" },
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
{ label: "4", action: "4", type: "number" },
|
|
111
|
+
{ label: "5", action: "5", type: "number" },
|
|
112
|
+
{ label: "6", action: "6", type: "number" },
|
|
113
|
+
{ label: "±", action: "negate", type: "function" },
|
|
114
|
+
{ label: "=", action: "equals", type: "equals" },
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
{ label: "1", action: "1", type: "number" },
|
|
118
|
+
{ label: "2", action: "2", type: "number" },
|
|
119
|
+
{ label: "3", action: "3", type: "number" },
|
|
120
|
+
{ label: "0", action: "0", type: "number" },
|
|
121
|
+
{ label: ".", action: ".", type: "number" },
|
|
122
|
+
],
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// Fixed layout constants
|
|
126
|
+
const BUTTON_WIDTH = 5;
|
|
127
|
+
const NUM_COLS = 5;
|
|
128
|
+
const NUM_ROWS = 6;
|
|
129
|
+
const CALC_WIDTH = BUTTON_WIDTH * NUM_COLS + 1; // 26 chars
|
|
130
|
+
const DISPLAY_LINES = 2;
|
|
131
|
+
|
|
132
|
+
// Get color for button type (with optional hover highlight)
|
|
133
|
+
function getButtonColor(type: Button["type"], isHovered: boolean): string {
|
|
134
|
+
if (isHovered) {
|
|
135
|
+
// Bright/inverted colors for hover
|
|
136
|
+
return C.BG_WHITE + "\x1b[30m"; // White background, black text
|
|
137
|
+
}
|
|
138
|
+
switch (type) {
|
|
139
|
+
case "number": return C.WHITE;
|
|
140
|
+
case "operator": return C.BRIGHT_YELLOW;
|
|
141
|
+
case "function": return C.BRIGHT_CYAN;
|
|
142
|
+
case "clear": return C.BRIGHT_RED;
|
|
143
|
+
case "equals": return C.BRIGHT_GREEN;
|
|
144
|
+
default: return C.WHITE;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Layout metrics
|
|
149
|
+
interface LayoutMetrics {
|
|
150
|
+
startX: number;
|
|
151
|
+
startY: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function calculateLayout(_viewport: ViewportInfo): LayoutMetrics {
|
|
155
|
+
// Position at top-left with 1 row/column gap
|
|
156
|
+
const startX = 1;
|
|
157
|
+
const startY = 1;
|
|
158
|
+
|
|
159
|
+
return { startX, startY };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Render the calculator with ANSI colors
|
|
163
|
+
function renderCalculator(): TextPropertyEntry[] {
|
|
164
|
+
const viewport = editor.getViewport();
|
|
165
|
+
if (!viewport) {
|
|
166
|
+
return [{ text: "No viewport\n", properties: {} }];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
state.lastViewport = viewport;
|
|
170
|
+
|
|
171
|
+
// Use cached layout to prevent jumping, or calculate new one
|
|
172
|
+
if (!cachedLayout) {
|
|
173
|
+
cachedLayout = calculateLayout(viewport);
|
|
174
|
+
}
|
|
175
|
+
const layout = cachedLayout;
|
|
176
|
+
const entries: TextPropertyEntry[] = [];
|
|
177
|
+
|
|
178
|
+
const addLine = (text: string): void => {
|
|
179
|
+
entries.push({ text: text + "\n", properties: {} });
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Top margin
|
|
183
|
+
for (let i = 0; i < layout.startY; i++) {
|
|
184
|
+
addLine("");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pad = " ".repeat(layout.startX);
|
|
188
|
+
|
|
189
|
+
// Unicode box drawing chars
|
|
190
|
+
const TL = "╭", TR = "╮", BL = "╰", BR = "╯";
|
|
191
|
+
const V = "│";
|
|
192
|
+
const LT = "├", RT = "┤", X = "┼";
|
|
193
|
+
|
|
194
|
+
// Generate border patterns dynamically
|
|
195
|
+
const cellWidth = BUTTON_WIDTH - 1; // 4 dashes per cell
|
|
196
|
+
const topBorder = TL + "─".repeat(CALC_WIDTH - 2) + TR;
|
|
197
|
+
const sepTop = LT + Array(NUM_COLS).fill("─".repeat(cellWidth)).join("┬") + RT;
|
|
198
|
+
const sepMid = LT + Array(NUM_COLS).fill("─".repeat(cellWidth)).join("┼") + RT;
|
|
199
|
+
const sepBot = BL + Array(NUM_COLS).fill("─".repeat(cellWidth)).join("┴") + BR;
|
|
200
|
+
|
|
201
|
+
// Display - top border
|
|
202
|
+
addLine(`${pad}${C.CYAN}${topBorder}${C.RESET}`);
|
|
203
|
+
|
|
204
|
+
// Expression line
|
|
205
|
+
let expr = state.expression || "";
|
|
206
|
+
const maxLen = CALC_WIDTH - 4;
|
|
207
|
+
if (expr.length > maxLen) expr = expr.slice(-maxLen);
|
|
208
|
+
addLine(`${pad}${C.CYAN}${V}${C.RESET} ${C.BRIGHT_GREEN}${expr.padStart(maxLen)}${C.RESET} ${C.CYAN}${V}${C.RESET}`);
|
|
209
|
+
|
|
210
|
+
// Result line with copy button on left - slightly different background
|
|
211
|
+
let result = state.error || state.result;
|
|
212
|
+
const copyBtnWidth = 6; // "Copy" + 2 spaces
|
|
213
|
+
const resultMaxLen = maxLen - copyBtnWidth;
|
|
214
|
+
if (result.length > resultMaxLen) result = result.slice(0, resultMaxLen);
|
|
215
|
+
const resultColor = state.error ? C.BRIGHT_RED : C.BRIGHT_GREEN;
|
|
216
|
+
const copyBtnColor = copyButtonHovered ? (C.BG_WHITE + "\x1b[30m") : (C.BG_BRIGHT_BLACK + C.BRIGHT_MAGENTA);
|
|
217
|
+
const resultBg = C.BG_BRIGHT_BLACK;
|
|
218
|
+
addLine(`${pad}${C.CYAN}${V}${C.RESET}${copyBtnColor}Copy${C.RESET}${resultBg} ${C.BOLD}${resultColor}${result.padStart(resultMaxLen)}${C.RESET}${resultBg} ${C.RESET}${C.CYAN}${V}${C.RESET}`);
|
|
219
|
+
|
|
220
|
+
// Separator between display and buttons
|
|
221
|
+
addLine(`${pad}${C.CYAN}${sepTop}${C.RESET}`);
|
|
222
|
+
|
|
223
|
+
// Button rows
|
|
224
|
+
for (let rowIdx = 0; rowIdx < BUTTON_LAYOUT.length; rowIdx++) {
|
|
225
|
+
const buttonRow = BUTTON_LAYOUT[rowIdx];
|
|
226
|
+
let line = `${pad}${C.CYAN}${V}${C.RESET}`;
|
|
227
|
+
|
|
228
|
+
for (let colIdx = 0; colIdx < buttonRow.length; colIdx++) {
|
|
229
|
+
const btn = buttonRow[colIdx];
|
|
230
|
+
const isHovered = hoveredButton?.row === rowIdx && hoveredButton?.col === colIdx;
|
|
231
|
+
const color = getButtonColor(btn.type, isHovered);
|
|
232
|
+
const label = btn.label;
|
|
233
|
+
const innerWidth = BUTTON_WIDTH - 1;
|
|
234
|
+
const leftSpace = Math.floor((innerWidth - label.length) / 2);
|
|
235
|
+
const rightSpace = innerWidth - label.length - leftSpace;
|
|
236
|
+
line += `${color}${C.BOLD}${" ".repeat(leftSpace)}${label}${" ".repeat(rightSpace)}${C.RESET}${C.CYAN}${V}${C.RESET}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
addLine(line);
|
|
240
|
+
|
|
241
|
+
// Row separator (except after last row)
|
|
242
|
+
if (rowIdx < BUTTON_LAYOUT.length - 1) {
|
|
243
|
+
addLine(`${pad}${C.CYAN}${sepMid}${C.RESET}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Bottom border
|
|
248
|
+
addLine(`${pad}${C.CYAN}${sepBot}${C.RESET}`);
|
|
249
|
+
|
|
250
|
+
// Help line
|
|
251
|
+
addLine("");
|
|
252
|
+
addLine(`${pad}${C.DIM} Esc:close =/Enter:calc Del:clear${C.RESET}`);
|
|
253
|
+
|
|
254
|
+
return entries;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check if click is on copy button (returns true if on copy button)
|
|
258
|
+
function isCopyButtonAt(contentCol: number, contentRow: number): boolean {
|
|
259
|
+
if (!cachedLayout) return false;
|
|
260
|
+
|
|
261
|
+
// Copy button is on result line (row 2 after top margin)
|
|
262
|
+
const resultLineY = cachedLayout.startY + 2; // top border + expression line
|
|
263
|
+
const copyBtnStartX = cachedLayout.startX + 1; // after left border
|
|
264
|
+
const copyBtnEndX = copyBtnStartX + 4; // "Copy" is 4 chars
|
|
265
|
+
|
|
266
|
+
return contentRow === resultLineY &&
|
|
267
|
+
contentCol >= copyBtnStartX &&
|
|
268
|
+
contentCol < copyBtnEndX;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Copy result to clipboard
|
|
272
|
+
function copyResultToClipboard(): void {
|
|
273
|
+
const textToCopy = state.error || state.result;
|
|
274
|
+
editor.copyToClipboard(textToCopy);
|
|
275
|
+
editor.setStatus(`Copied: ${textToCopy}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Get button position at content-relative coordinates
|
|
279
|
+
function getButtonPosition(contentCol: number, contentRow: number): { row: number; col: number } | null {
|
|
280
|
+
if (!cachedLayout) return null;
|
|
281
|
+
|
|
282
|
+
// Button area starts after: marginY + display(2 lines) + borders(2)
|
|
283
|
+
const buttonAreaStartY = cachedLayout.startY + DISPLAY_LINES + 2;
|
|
284
|
+
const buttonAreaStartX = cachedLayout.startX + 1; // +1 for left border
|
|
285
|
+
|
|
286
|
+
const relY = contentRow - buttonAreaStartY;
|
|
287
|
+
const relX = contentCol - buttonAreaStartX;
|
|
288
|
+
|
|
289
|
+
if (relX < 0 || relY < 0) return null;
|
|
290
|
+
if (relX >= BUTTON_WIDTH * NUM_COLS) return null;
|
|
291
|
+
|
|
292
|
+
// Check if on horizontal separator line (odd rows are separators)
|
|
293
|
+
if (relY % 2 === 1) return null;
|
|
294
|
+
|
|
295
|
+
// Check if on vertical border (every BUTTON_WIDTH chars, minus 1 for the separator)
|
|
296
|
+
const posInButton = relX % BUTTON_WIDTH;
|
|
297
|
+
if (posInButton === BUTTON_WIDTH - 1) return null; // On the | border
|
|
298
|
+
|
|
299
|
+
// Each button row = 2 lines (content + separator)
|
|
300
|
+
const buttonRowIdx = Math.floor(relY / 2);
|
|
301
|
+
if (buttonRowIdx < 0 || buttonRowIdx >= NUM_ROWS) return null;
|
|
302
|
+
|
|
303
|
+
// Column
|
|
304
|
+
const buttonColIdx = Math.floor(relX / BUTTON_WIDTH);
|
|
305
|
+
if (buttonColIdx < 0 || buttonColIdx >= NUM_COLS) return null;
|
|
306
|
+
|
|
307
|
+
return { row: buttonRowIdx, col: buttonColIdx };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Get button at content-relative position
|
|
311
|
+
function getButtonAt(contentCol: number, contentRow: number): Button | null {
|
|
312
|
+
const pos = getButtonPosition(contentCol, contentRow);
|
|
313
|
+
if (!pos) return null;
|
|
314
|
+
return BUTTON_LAYOUT[pos.row][pos.col];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Expression parser
|
|
318
|
+
interface Token {
|
|
319
|
+
type: "number" | "operator" | "lparen" | "rparen" | "function" | "constant";
|
|
320
|
+
value: string | number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Known functions and constants
|
|
324
|
+
const FUNCTIONS = ["sqrt", "ln", "log", "sin", "cos", "tan", "asin", "acos", "atan", "abs"];
|
|
325
|
+
const CONSTANTS: Record<string, number> = {
|
|
326
|
+
pi: Math.PI,
|
|
327
|
+
e: Math.E,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function tokenize(expr: string): Token[] {
|
|
331
|
+
const tokens: Token[] = [];
|
|
332
|
+
let i = 0;
|
|
333
|
+
|
|
334
|
+
while (i < expr.length) {
|
|
335
|
+
const ch = expr[i];
|
|
336
|
+
|
|
337
|
+
if (/\s/.test(ch)) { i++; continue; }
|
|
338
|
+
|
|
339
|
+
// Numbers
|
|
340
|
+
if (/[0-9.]/.test(ch)) {
|
|
341
|
+
let num = "";
|
|
342
|
+
while (i < expr.length && /[0-9.]/.test(expr[i])) {
|
|
343
|
+
num += expr[i];
|
|
344
|
+
i++;
|
|
345
|
+
}
|
|
346
|
+
tokens.push({ type: "number", value: parseFloat(num) });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Identifiers (functions and constants)
|
|
351
|
+
if (/[a-zA-Z]/.test(ch)) {
|
|
352
|
+
let ident = "";
|
|
353
|
+
while (i < expr.length && /[a-zA-Z0-9]/.test(expr[i])) {
|
|
354
|
+
ident += expr[i];
|
|
355
|
+
i++;
|
|
356
|
+
}
|
|
357
|
+
if (FUNCTIONS.includes(ident)) {
|
|
358
|
+
tokens.push({ type: "function", value: ident });
|
|
359
|
+
} else if (ident in CONSTANTS) {
|
|
360
|
+
tokens.push({ type: "constant", value: ident });
|
|
361
|
+
} else {
|
|
362
|
+
throw new Error(`Unknown: ${ident}`);
|
|
363
|
+
}
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (ch === "(") { tokens.push({ type: "lparen", value: "(" }); i++; continue; }
|
|
368
|
+
if (ch === ")") { tokens.push({ type: "rparen", value: ")" }); i++; continue; }
|
|
369
|
+
if (/[+\-*/^]/.test(ch)) { tokens.push({ type: "operator", value: ch }); i++; continue; }
|
|
370
|
+
|
|
371
|
+
i++;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return tokens;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Precedence: + - < * / < ^ < unary - < functions
|
|
378
|
+
function parseExpression(tokens: Token[], pos: { idx: number }): number {
|
|
379
|
+
let left = parseTerm(tokens, pos);
|
|
380
|
+
|
|
381
|
+
while (pos.idx < tokens.length) {
|
|
382
|
+
const token = tokens[pos.idx];
|
|
383
|
+
if (token.type === "operator" && (token.value === "+" || token.value === "-")) {
|
|
384
|
+
pos.idx++;
|
|
385
|
+
const right = parseTerm(tokens, pos);
|
|
386
|
+
left = token.value === "+" ? left + right : left - right;
|
|
387
|
+
} else {
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return left;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parseTerm(tokens: Token[], pos: { idx: number }): number {
|
|
396
|
+
let left = parsePower(tokens, pos);
|
|
397
|
+
|
|
398
|
+
while (pos.idx < tokens.length) {
|
|
399
|
+
const token = tokens[pos.idx];
|
|
400
|
+
if (token.type === "operator" && (token.value === "*" || token.value === "/")) {
|
|
401
|
+
pos.idx++;
|
|
402
|
+
const right = parsePower(tokens, pos);
|
|
403
|
+
if (token.value === "*") {
|
|
404
|
+
left = left * right;
|
|
405
|
+
} else {
|
|
406
|
+
if (right === 0) throw new Error("Div by 0");
|
|
407
|
+
left = left / right;
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return left;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parsePower(tokens: Token[], pos: { idx: number }): number {
|
|
418
|
+
const base = parseUnary(tokens, pos);
|
|
419
|
+
|
|
420
|
+
if (pos.idx < tokens.length && tokens[pos.idx].type === "operator" && tokens[pos.idx].value === "^") {
|
|
421
|
+
pos.idx++;
|
|
422
|
+
const exp = parsePower(tokens, pos); // Right associative
|
|
423
|
+
return Math.pow(base, exp);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return base;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function parseUnary(tokens: Token[], pos: { idx: number }): number {
|
|
430
|
+
if (pos.idx >= tokens.length) throw new Error("Unexpected end");
|
|
431
|
+
|
|
432
|
+
const token = tokens[pos.idx];
|
|
433
|
+
|
|
434
|
+
if (token.type === "operator" && token.value === "-") {
|
|
435
|
+
pos.idx++;
|
|
436
|
+
return -parseUnary(tokens, pos);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return parsePrimary(tokens, pos);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function parsePrimary(tokens: Token[], pos: { idx: number }): number {
|
|
443
|
+
if (pos.idx >= tokens.length) throw new Error("Unexpected end");
|
|
444
|
+
|
|
445
|
+
const token = tokens[pos.idx];
|
|
446
|
+
|
|
447
|
+
// Function call
|
|
448
|
+
if (token.type === "function") {
|
|
449
|
+
const fname = token.value as string;
|
|
450
|
+
pos.idx++;
|
|
451
|
+
if (pos.idx >= tokens.length || tokens[pos.idx].type !== "lparen") {
|
|
452
|
+
throw new Error(`Expected ( after ${fname}`);
|
|
453
|
+
}
|
|
454
|
+
pos.idx++; // skip (
|
|
455
|
+
const arg = parseExpression(tokens, pos);
|
|
456
|
+
if (pos.idx >= tokens.length || tokens[pos.idx].type !== "rparen") {
|
|
457
|
+
throw new Error("Missing )");
|
|
458
|
+
}
|
|
459
|
+
pos.idx++; // skip )
|
|
460
|
+
|
|
461
|
+
switch (fname) {
|
|
462
|
+
case "sqrt": return Math.sqrt(arg);
|
|
463
|
+
case "ln": return Math.log(arg);
|
|
464
|
+
case "log": return Math.log10(arg);
|
|
465
|
+
case "sin": return Math.sin(arg);
|
|
466
|
+
case "cos": return Math.cos(arg);
|
|
467
|
+
case "tan": return Math.tan(arg);
|
|
468
|
+
case "asin": return Math.asin(arg);
|
|
469
|
+
case "acos": return Math.acos(arg);
|
|
470
|
+
case "atan": return Math.atan(arg);
|
|
471
|
+
case "abs": return Math.abs(arg);
|
|
472
|
+
default: throw new Error(`Unknown function: ${fname}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Constant
|
|
477
|
+
if (token.type === "constant") {
|
|
478
|
+
pos.idx++;
|
|
479
|
+
return CONSTANTS[token.value as string];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Number
|
|
483
|
+
if (token.type === "number") {
|
|
484
|
+
pos.idx++;
|
|
485
|
+
return token.value as number;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Parenthesized expression
|
|
489
|
+
if (token.type === "lparen") {
|
|
490
|
+
pos.idx++;
|
|
491
|
+
const result = parseExpression(tokens, pos);
|
|
492
|
+
if (pos.idx >= tokens.length || tokens[pos.idx].type !== "rparen") {
|
|
493
|
+
throw new Error("Missing )");
|
|
494
|
+
}
|
|
495
|
+
pos.idx++;
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
throw new Error("Syntax error");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function evaluateExpression(expr: string): string {
|
|
503
|
+
if (!expr.trim()) return "0";
|
|
504
|
+
|
|
505
|
+
const tokens = tokenize(expr);
|
|
506
|
+
if (tokens.length === 0) return "0";
|
|
507
|
+
|
|
508
|
+
const pos = { idx: 0 };
|
|
509
|
+
const result = parseExpression(tokens, pos);
|
|
510
|
+
|
|
511
|
+
if (pos.idx < tokens.length) throw new Error("Syntax error");
|
|
512
|
+
|
|
513
|
+
if (Number.isInteger(result)) {
|
|
514
|
+
return result.toString();
|
|
515
|
+
} else {
|
|
516
|
+
return parseFloat(result.toFixed(10)).toString();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Handle button press
|
|
521
|
+
function handleButton(button: Button): void {
|
|
522
|
+
state.error = "";
|
|
523
|
+
|
|
524
|
+
switch (button.action) {
|
|
525
|
+
case "clear":
|
|
526
|
+
state.expression = "";
|
|
527
|
+
state.result = "0";
|
|
528
|
+
break;
|
|
529
|
+
case "backspace":
|
|
530
|
+
if (state.expression.length > 0) {
|
|
531
|
+
state.expression = state.expression.slice(0, -1);
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
case "negate":
|
|
535
|
+
// Toggle sign: if expression is empty, negate last result; otherwise toggle current number
|
|
536
|
+
if (state.expression === "") {
|
|
537
|
+
// Use negated result as new expression
|
|
538
|
+
if (state.result !== "0") {
|
|
539
|
+
const num = parseFloat(state.result);
|
|
540
|
+
state.expression = (-num).toString();
|
|
541
|
+
state.result = state.expression;
|
|
542
|
+
} else {
|
|
543
|
+
state.expression = "-";
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
// Try to toggle sign of last number in expression
|
|
547
|
+
const match = state.expression.match(/(-?\d+\.?\d*)$/);
|
|
548
|
+
if (match) {
|
|
549
|
+
const numStr = match[1];
|
|
550
|
+
const prefix = state.expression.slice(0, state.expression.length - numStr.length);
|
|
551
|
+
const num = parseFloat(numStr);
|
|
552
|
+
state.expression = prefix + (-num).toString();
|
|
553
|
+
} else {
|
|
554
|
+
// No number at end, just add minus
|
|
555
|
+
state.expression += "-";
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
break;
|
|
559
|
+
case "equals":
|
|
560
|
+
try {
|
|
561
|
+
state.result = evaluateExpression(state.expression);
|
|
562
|
+
} catch (e) {
|
|
563
|
+
state.error = e instanceof Error ? e.message : "Error";
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
default:
|
|
567
|
+
state.expression += button.action;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
updateDisplay();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function updateDisplay(): void {
|
|
575
|
+
if (state.bufferId) {
|
|
576
|
+
const entries = renderCalculator();
|
|
577
|
+
editor.setVirtualBufferContent(state.bufferId, entries);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Mouse click handler
|
|
582
|
+
globalThis.onCalculatorMouseClick = function (data: {
|
|
583
|
+
column: number;
|
|
584
|
+
row: number;
|
|
585
|
+
button: string;
|
|
586
|
+
modifiers: string;
|
|
587
|
+
content_x: number;
|
|
588
|
+
content_y: number;
|
|
589
|
+
}): boolean {
|
|
590
|
+
if (data.button !== "left") return true;
|
|
591
|
+
|
|
592
|
+
const activeBuffer = editor.getActiveBufferId();
|
|
593
|
+
if (activeBuffer !== state.bufferId || state.bufferId === 0) return true;
|
|
594
|
+
|
|
595
|
+
// Convert screen coordinates to content-relative coordinates
|
|
596
|
+
const relCol = data.column - data.content_x;
|
|
597
|
+
const relRow = data.row - data.content_y;
|
|
598
|
+
|
|
599
|
+
// Check for copy button click
|
|
600
|
+
if (isCopyButtonAt(relCol, relRow)) {
|
|
601
|
+
copyResultToClipboard();
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const button = getButtonAt(relCol, relRow);
|
|
606
|
+
if (button) {
|
|
607
|
+
handleButton(button);
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return true;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// Keyboard handlers
|
|
615
|
+
globalThis.calc_digit_0 = function (): void { handleButton({ label: "0", action: "0", type: "number" }); };
|
|
616
|
+
globalThis.calc_digit_1 = function (): void { handleButton({ label: "1", action: "1", type: "number" }); };
|
|
617
|
+
globalThis.calc_digit_2 = function (): void { handleButton({ label: "2", action: "2", type: "number" }); };
|
|
618
|
+
globalThis.calc_digit_3 = function (): void { handleButton({ label: "3", action: "3", type: "number" }); };
|
|
619
|
+
globalThis.calc_digit_4 = function (): void { handleButton({ label: "4", action: "4", type: "number" }); };
|
|
620
|
+
globalThis.calc_digit_5 = function (): void { handleButton({ label: "5", action: "5", type: "number" }); };
|
|
621
|
+
globalThis.calc_digit_6 = function (): void { handleButton({ label: "6", action: "6", type: "number" }); };
|
|
622
|
+
globalThis.calc_digit_7 = function (): void { handleButton({ label: "7", action: "7", type: "number" }); };
|
|
623
|
+
globalThis.calc_digit_8 = function (): void { handleButton({ label: "8", action: "8", type: "number" }); };
|
|
624
|
+
globalThis.calc_digit_9 = function (): void { handleButton({ label: "9", action: "9", type: "number" }); };
|
|
625
|
+
|
|
626
|
+
globalThis.calc_add = function (): void { handleButton({ label: "+", action: "+", type: "operator" }); };
|
|
627
|
+
globalThis.calc_subtract = function (): void { handleButton({ label: "-", action: "-", type: "operator" }); };
|
|
628
|
+
globalThis.calc_multiply = function (): void { handleButton({ label: "×", action: "*", type: "operator" }); };
|
|
629
|
+
globalThis.calc_divide = function (): void { handleButton({ label: "÷", action: "/", type: "operator" }); };
|
|
630
|
+
globalThis.calc_lparen = function (): void { handleButton({ label: "(", action: "(", type: "function" }); };
|
|
631
|
+
globalThis.calc_rparen = function (): void { handleButton({ label: ")", action: ")", type: "function" }); };
|
|
632
|
+
globalThis.calc_dot = function (): void { handleButton({ label: ".", action: ".", type: "number" }); };
|
|
633
|
+
globalThis.calc_equals = function (): void { handleButton({ label: "=", action: "equals", type: "equals" }); };
|
|
634
|
+
globalThis.calc_clear = function (): void { handleButton({ label: "C", action: "clear", type: "clear" }); };
|
|
635
|
+
globalThis.calc_backspace = function (): void { handleButton({ label: "⌫", action: "backspace", type: "clear" }); };
|
|
636
|
+
globalThis.calc_power = function (): void { handleButton({ label: "^", action: "^", type: "operator" }); };
|
|
637
|
+
|
|
638
|
+
// Letter handlers for typing function names
|
|
639
|
+
const letterHandler = (ch: string) => () => {
|
|
640
|
+
state.error = "";
|
|
641
|
+
state.expression += ch;
|
|
642
|
+
updateDisplay();
|
|
643
|
+
};
|
|
644
|
+
for (const ch of "abcdefghijklmnopqrstuvwxyz") {
|
|
645
|
+
(globalThis as Record<string, unknown>)[`calc_letter_${ch}`] = letterHandler(ch);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
globalThis.calc_close = function (): void {
|
|
649
|
+
if (state.bufferId) {
|
|
650
|
+
editor.closeBuffer(state.bufferId);
|
|
651
|
+
state.bufferId = 0;
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Open calculator
|
|
656
|
+
globalThis.calculator_open = async function (): Promise<void> {
|
|
657
|
+
if (state.bufferId) {
|
|
658
|
+
const bufferInfo = editor.getBufferInfo(state.bufferId);
|
|
659
|
+
if (bufferInfo) {
|
|
660
|
+
editor.showBuffer(state.bufferId);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
state.bufferId = 0;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
state.expression = "";
|
|
667
|
+
state.result = "0";
|
|
668
|
+
state.error = "";
|
|
669
|
+
cachedLayout = null; // Reset layout for fresh calculation
|
|
670
|
+
hoveredButton = null; // Reset hover state
|
|
671
|
+
copyButtonHovered = false; // Reset copy button hover state
|
|
672
|
+
|
|
673
|
+
const modeBindings: [string, string][] = [
|
|
674
|
+
["0", "calc_digit_0"], ["1", "calc_digit_1"], ["2", "calc_digit_2"],
|
|
675
|
+
["3", "calc_digit_3"], ["4", "calc_digit_4"], ["5", "calc_digit_5"],
|
|
676
|
+
["6", "calc_digit_6"], ["7", "calc_digit_7"], ["8", "calc_digit_8"],
|
|
677
|
+
["9", "calc_digit_9"],
|
|
678
|
+
["+", "calc_add"], ["-", "calc_subtract"], ["*", "calc_multiply"],
|
|
679
|
+
["/", "calc_divide"], ["(", "calc_lparen"], [")", "calc_rparen"],
|
|
680
|
+
[".", "calc_dot"], ["^", "calc_power"],
|
|
681
|
+
["Return", "calc_equals"], ["=", "calc_equals"],
|
|
682
|
+
["Delete", "calc_clear"],
|
|
683
|
+
["Backspace", "calc_backspace"],
|
|
684
|
+
["Escape", "calc_close"],
|
|
685
|
+
];
|
|
686
|
+
// Add letter bindings for typing function names
|
|
687
|
+
for (const ch of "abcdefghijklmnopqrstuvwxyz") {
|
|
688
|
+
modeBindings.push([ch, `calc_letter_${ch}`]);
|
|
689
|
+
}
|
|
690
|
+
editor.defineMode("calculator", "special", modeBindings, true);
|
|
691
|
+
|
|
692
|
+
const cmds = [
|
|
693
|
+
["calc_digit_0", "0"], ["calc_digit_1", "1"], ["calc_digit_2", "2"],
|
|
694
|
+
["calc_digit_3", "3"], ["calc_digit_4", "4"], ["calc_digit_5", "5"],
|
|
695
|
+
["calc_digit_6", "6"], ["calc_digit_7", "7"], ["calc_digit_8", "8"],
|
|
696
|
+
["calc_digit_9", "9"], ["calc_add", "+"], ["calc_subtract", "-"],
|
|
697
|
+
["calc_multiply", "*"], ["calc_divide", "/"], ["calc_lparen", "("],
|
|
698
|
+
["calc_rparen", ")"], ["calc_dot", "."], ["calc_equals", "="],
|
|
699
|
+
["calc_clear", "C"], ["calc_backspace", "BS"], ["calc_close", "close"],
|
|
700
|
+
["calc_power", "^"],
|
|
701
|
+
];
|
|
702
|
+
for (const [name, desc] of cmds) {
|
|
703
|
+
editor.registerCommand(name, `Calc: ${desc}`, name, "calculator");
|
|
704
|
+
}
|
|
705
|
+
// Register letter commands
|
|
706
|
+
for (const ch of "abcdefghijklmnopqrstuvwxyz") {
|
|
707
|
+
editor.registerCommand(`calc_letter_${ch}`, `Calc: ${ch}`, `calc_letter_${ch}`, "calculator");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const entries = renderCalculator();
|
|
711
|
+
|
|
712
|
+
state.bufferId = await editor.createVirtualBuffer({
|
|
713
|
+
name: "*Calculator*",
|
|
714
|
+
mode: "calculator",
|
|
715
|
+
read_only: true,
|
|
716
|
+
entries,
|
|
717
|
+
show_line_numbers: false,
|
|
718
|
+
show_cursors: false,
|
|
719
|
+
editing_disabled: true,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
state.splitId = editor.getActiveSplitId();
|
|
723
|
+
|
|
724
|
+
editor.setStatus("Calculator opened");
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// Mouse move handler for hover effect
|
|
728
|
+
globalThis.onCalculatorMouseMove = function (data: {
|
|
729
|
+
column: number;
|
|
730
|
+
row: number;
|
|
731
|
+
content_x: number;
|
|
732
|
+
content_y: number;
|
|
733
|
+
}): boolean {
|
|
734
|
+
const activeBuffer = editor.getActiveBufferId();
|
|
735
|
+
if (activeBuffer !== state.bufferId || state.bufferId === 0) return true;
|
|
736
|
+
|
|
737
|
+
// Convert screen coordinates to content-relative coordinates
|
|
738
|
+
const relCol = data.column - data.content_x;
|
|
739
|
+
const relRow = data.row - data.content_y;
|
|
740
|
+
|
|
741
|
+
const newHover = getButtonPosition(relCol, relRow);
|
|
742
|
+
const newCopyHover = isCopyButtonAt(relCol, relRow);
|
|
743
|
+
|
|
744
|
+
// Check if hover changed
|
|
745
|
+
const buttonChanged =
|
|
746
|
+
(newHover === null && hoveredButton !== null) ||
|
|
747
|
+
(newHover !== null && hoveredButton === null) ||
|
|
748
|
+
(newHover !== null && hoveredButton !== null &&
|
|
749
|
+
(newHover.row !== hoveredButton.row || newHover.col !== hoveredButton.col));
|
|
750
|
+
const copyChanged = newCopyHover !== copyButtonHovered;
|
|
751
|
+
|
|
752
|
+
if (buttonChanged || copyChanged) {
|
|
753
|
+
hoveredButton = newHover;
|
|
754
|
+
copyButtonHovered = newCopyHover;
|
|
755
|
+
updateDisplay();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return true;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// Register hooks
|
|
762
|
+
editor.on("mouse_click", "onCalculatorMouseClick");
|
|
763
|
+
editor.on("mouse_move", "onCalculatorMouseMove");
|
|
764
|
+
|
|
765
|
+
// Register main command
|
|
766
|
+
editor.registerCommand("Calculator", "Open calculator", "calculator_open", "normal");
|
|
767
|
+
|
|
768
|
+
editor.setStatus("Calculator plugin loaded");
|
package/plugins/lib/fresh.d.ts
CHANGED
|
@@ -183,6 +183,12 @@ interface TextPropertyEntry {
|
|
|
183
183
|
properties: Record<string, unknown>;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** Result from createVirtualBufferInSplit */
|
|
187
|
+
interface CreateVirtualBufferResult {
|
|
188
|
+
buffer_id: number;
|
|
189
|
+
split_id?: number | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
186
192
|
/** Configuration for createVirtualBufferInSplit */
|
|
187
193
|
interface CreateVirtualBufferOptions {
|
|
188
194
|
/** Buffer name shown in status bar (convention: "*Name*") */
|
|
@@ -380,6 +386,14 @@ interface EditorAPI {
|
|
|
380
386
|
setPromptSuggestions(suggestions: PromptSuggestion[]): boolean;
|
|
381
387
|
|
|
382
388
|
// === Buffer Mutations ===
|
|
389
|
+
/**
|
|
390
|
+
* Copy text to the system clipboard
|
|
391
|
+
*
|
|
392
|
+
* Copies the provided text to both the internal and system clipboard.
|
|
393
|
+
* Uses OSC 52 and arboard for cross-platform compatibility.
|
|
394
|
+
* @param text - Text to copy to clipboard
|
|
395
|
+
*/
|
|
396
|
+
setClipboard(text: string): void;
|
|
383
397
|
/**
|
|
384
398
|
* Insert text at a byte position in a buffer
|
|
385
399
|
*
|
|
@@ -462,9 +476,10 @@ interface EditorAPI {
|
|
|
462
476
|
* @param description - Human-readable description
|
|
463
477
|
* @param action - JavaScript function name to call when command is triggered
|
|
464
478
|
* @param contexts - Comma-separated list of contexts (e.g., "normal,prompt")
|
|
479
|
+
* @param source - Plugin source name (empty string for builtin)
|
|
465
480
|
* @returns true if command was registered
|
|
466
481
|
*/
|
|
467
|
-
registerCommand(name: string, description: string, action: string, contexts: string): boolean;
|
|
482
|
+
registerCommand(name: string, description: string, action: string, contexts: string, source: string): boolean;
|
|
468
483
|
/**
|
|
469
484
|
* Unregister a custom command by name
|
|
470
485
|
* @param name - The name of the command to unregister
|
|
@@ -829,7 +844,7 @@ interface EditorAPI {
|
|
|
829
844
|
* panel_id: "search"
|
|
830
845
|
* });
|
|
831
846
|
*/
|
|
832
|
-
createVirtualBufferInSplit(options: CreateVirtualBufferOptions): Promise<
|
|
847
|
+
createVirtualBufferInSplit(options: CreateVirtualBufferOptions): Promise<CreateVirtualBufferResult>;
|
|
833
848
|
/**
|
|
834
849
|
* Create a virtual buffer in an existing split
|
|
835
850
|
* @param options - Configuration for the virtual buffer
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/// <reference path="./lib/fresh.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live Grep Plugin
|
|
5
|
+
*
|
|
6
|
+
* Project-wide search with ripgrep and live preview.
|
|
7
|
+
* - Type to search across all files
|
|
8
|
+
* - Navigate results with Up/Down to see preview
|
|
9
|
+
* - Press Enter to open file at location
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface GrepMatch {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
column: number;
|
|
16
|
+
content: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// State management
|
|
20
|
+
let grepResults: GrepMatch[] = [];
|
|
21
|
+
let previewBufferId: number | null = null;
|
|
22
|
+
let previewSplitId: number | null = null;
|
|
23
|
+
let originalSplitId: number | null = null;
|
|
24
|
+
let lastQuery: string = "";
|
|
25
|
+
let searchDebounceTimer: number | null = null;
|
|
26
|
+
let previewCreated: boolean = false;
|
|
27
|
+
|
|
28
|
+
// Parse ripgrep output line
|
|
29
|
+
// Format: file:line:column:content
|
|
30
|
+
function parseRipgrepLine(line: string): GrepMatch | null {
|
|
31
|
+
const match = line.match(/^([^:]+):(\d+):(\d+):(.*)$/);
|
|
32
|
+
if (match) {
|
|
33
|
+
return {
|
|
34
|
+
file: match[1],
|
|
35
|
+
line: parseInt(match[2], 10),
|
|
36
|
+
column: parseInt(match[3], 10),
|
|
37
|
+
content: match[4],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse ripgrep output into suggestions
|
|
44
|
+
function parseRipgrepOutput(stdout: string): {
|
|
45
|
+
results: GrepMatch[];
|
|
46
|
+
suggestions: PromptSuggestion[];
|
|
47
|
+
} {
|
|
48
|
+
const results: GrepMatch[] = [];
|
|
49
|
+
const suggestions: PromptSuggestion[] = [];
|
|
50
|
+
|
|
51
|
+
for (const line of stdout.split("\n")) {
|
|
52
|
+
if (!line.trim()) continue;
|
|
53
|
+
const match = parseRipgrepLine(line);
|
|
54
|
+
if (match) {
|
|
55
|
+
results.push(match);
|
|
56
|
+
|
|
57
|
+
// Truncate long content for display
|
|
58
|
+
const displayContent =
|
|
59
|
+
match.content.length > 60
|
|
60
|
+
? match.content.substring(0, 57) + "..."
|
|
61
|
+
: match.content;
|
|
62
|
+
|
|
63
|
+
suggestions.push({
|
|
64
|
+
text: `${match.file}:${match.line}`,
|
|
65
|
+
description: displayContent.trim(),
|
|
66
|
+
value: `${results.length - 1}`, // Store index as value
|
|
67
|
+
disabled: false,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Limit to 100 results for performance
|
|
71
|
+
if (results.length >= 100) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { results, suggestions };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create or update preview buffer with file content
|
|
81
|
+
async function updatePreview(match: GrepMatch): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
// Read the file content
|
|
84
|
+
const content = await editor.readFile(match.file);
|
|
85
|
+
const lines = content.split("\n");
|
|
86
|
+
|
|
87
|
+
// Calculate context window (5 lines before and after)
|
|
88
|
+
const contextBefore = 5;
|
|
89
|
+
const contextAfter = 5;
|
|
90
|
+
const startLine = Math.max(0, match.line - 1 - contextBefore);
|
|
91
|
+
const endLine = Math.min(lines.length, match.line + contextAfter);
|
|
92
|
+
|
|
93
|
+
// Build preview entries with highlighting
|
|
94
|
+
const entries: TextPropertyEntry[] = [];
|
|
95
|
+
|
|
96
|
+
// Header
|
|
97
|
+
entries.push({
|
|
98
|
+
text: ` ${match.file}:${match.line}:${match.column}\n`,
|
|
99
|
+
properties: { type: "header" },
|
|
100
|
+
});
|
|
101
|
+
entries.push({
|
|
102
|
+
text: "─".repeat(60) + "\n",
|
|
103
|
+
properties: { type: "separator" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Content lines with line numbers
|
|
107
|
+
for (let i = startLine; i < endLine; i++) {
|
|
108
|
+
const lineNum = i + 1;
|
|
109
|
+
const lineContent = lines[i] || "";
|
|
110
|
+
const isMatchLine = lineNum === match.line;
|
|
111
|
+
const prefix = isMatchLine ? "▶ " : " ";
|
|
112
|
+
const lineNumStr = String(lineNum).padStart(4, " ");
|
|
113
|
+
|
|
114
|
+
entries.push({
|
|
115
|
+
text: `${prefix}${lineNumStr} │ ${lineContent}\n`,
|
|
116
|
+
properties: {
|
|
117
|
+
type: isMatchLine ? "match" : "context",
|
|
118
|
+
line: lineNum,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create or update the preview buffer
|
|
124
|
+
if (previewBufferId === null) {
|
|
125
|
+
// Define mode for preview buffer
|
|
126
|
+
editor.defineMode("live-grep-preview", "special", [["q", "close_buffer"]], true);
|
|
127
|
+
|
|
128
|
+
// Create preview in a split on the right
|
|
129
|
+
const result = await editor.createVirtualBufferInSplit({
|
|
130
|
+
name: "*Preview*",
|
|
131
|
+
mode: "live-grep-preview",
|
|
132
|
+
read_only: true,
|
|
133
|
+
entries,
|
|
134
|
+
ratio: 0.5,
|
|
135
|
+
direction: "vertical",
|
|
136
|
+
panel_id: "live-grep-preview",
|
|
137
|
+
show_line_numbers: false,
|
|
138
|
+
editing_disabled: true,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Extract buffer and split IDs from result
|
|
142
|
+
previewBufferId = result.buffer_id;
|
|
143
|
+
previewSplitId = result.split_id ?? null;
|
|
144
|
+
|
|
145
|
+
// Return focus to original split so prompt stays active
|
|
146
|
+
if (originalSplitId !== null) {
|
|
147
|
+
editor.focusSplit(originalSplitId);
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
// Update existing buffer content
|
|
151
|
+
editor.setVirtualBufferContent(previewBufferId, entries);
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
editor.debug(`Failed to update preview: ${e}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Close preview buffer and its split
|
|
159
|
+
function closePreview(): void {
|
|
160
|
+
// Close the buffer first
|
|
161
|
+
if (previewBufferId !== null) {
|
|
162
|
+
editor.closeBuffer(previewBufferId);
|
|
163
|
+
previewBufferId = null;
|
|
164
|
+
}
|
|
165
|
+
// Then close the split
|
|
166
|
+
if (previewSplitId !== null) {
|
|
167
|
+
editor.closeSplit(previewSplitId);
|
|
168
|
+
previewSplitId = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Run ripgrep search
|
|
173
|
+
async function runSearch(query: string): Promise<void> {
|
|
174
|
+
if (!query || query.trim().length < 2) {
|
|
175
|
+
editor.setPromptSuggestions([]);
|
|
176
|
+
grepResults = [];
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Avoid duplicate searches
|
|
181
|
+
if (query === lastQuery) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
lastQuery = query;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const result = await editor.spawnProcess("rg", [
|
|
188
|
+
"--line-number",
|
|
189
|
+
"--column",
|
|
190
|
+
"--no-heading",
|
|
191
|
+
"--color=never",
|
|
192
|
+
"--smart-case",
|
|
193
|
+
"--max-count=100",
|
|
194
|
+
"-g", "!.git",
|
|
195
|
+
"-g", "!node_modules",
|
|
196
|
+
"-g", "!target",
|
|
197
|
+
"-g", "!*.lock",
|
|
198
|
+
"--",
|
|
199
|
+
query,
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
if (result.exit_code === 0) {
|
|
203
|
+
const { results, suggestions } = parseRipgrepOutput(result.stdout);
|
|
204
|
+
grepResults = results;
|
|
205
|
+
editor.setPromptSuggestions(suggestions);
|
|
206
|
+
|
|
207
|
+
if (results.length > 0) {
|
|
208
|
+
editor.setStatus(`Found ${results.length} matches`);
|
|
209
|
+
// Show preview of first result
|
|
210
|
+
await updatePreview(results[0]);
|
|
211
|
+
} else {
|
|
212
|
+
editor.setStatus("No matches found");
|
|
213
|
+
}
|
|
214
|
+
} else if (result.exit_code === 1) {
|
|
215
|
+
// No matches
|
|
216
|
+
grepResults = [];
|
|
217
|
+
editor.setPromptSuggestions([]);
|
|
218
|
+
editor.setStatus("No matches found");
|
|
219
|
+
} else {
|
|
220
|
+
editor.setStatus(`Search error: ${result.stderr}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {
|
|
223
|
+
editor.setStatus(`Search error: ${e}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Start live grep
|
|
228
|
+
globalThis.start_live_grep = function (): void {
|
|
229
|
+
// Clear previous state
|
|
230
|
+
grepResults = [];
|
|
231
|
+
lastQuery = "";
|
|
232
|
+
previewBufferId = null;
|
|
233
|
+
|
|
234
|
+
// Remember original split to keep focus
|
|
235
|
+
originalSplitId = editor.getActiveSplitId();
|
|
236
|
+
|
|
237
|
+
// Start the prompt
|
|
238
|
+
editor.startPrompt("Live grep: ", "live-grep");
|
|
239
|
+
editor.setStatus("Type to search (min 2 chars)...");
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Handle prompt input changes
|
|
243
|
+
globalThis.onLiveGrepPromptChanged = function (args: {
|
|
244
|
+
prompt_type: string;
|
|
245
|
+
input: string;
|
|
246
|
+
}): boolean {
|
|
247
|
+
if (args.prompt_type !== "live-grep") {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Debounce search to avoid too many requests while typing
|
|
252
|
+
if (searchDebounceTimer !== null) {
|
|
253
|
+
// Can't actually cancel in this runtime, but we track it
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Run search (with small delay effect via async)
|
|
257
|
+
runSearch(args.input);
|
|
258
|
+
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Handle selection changes - update preview
|
|
263
|
+
globalThis.onLiveGrepSelectionChanged = function (args: {
|
|
264
|
+
prompt_type: string;
|
|
265
|
+
selected_index: number;
|
|
266
|
+
}): boolean {
|
|
267
|
+
if (args.prompt_type !== "live-grep") {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const match = grepResults[args.selected_index];
|
|
272
|
+
if (match) {
|
|
273
|
+
updatePreview(match);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Handle prompt confirmation - open file
|
|
280
|
+
globalThis.onLiveGrepPromptConfirmed = function (args: {
|
|
281
|
+
prompt_type: string;
|
|
282
|
+
selected_index: number | null;
|
|
283
|
+
input: string;
|
|
284
|
+
}): boolean {
|
|
285
|
+
if (args.prompt_type !== "live-grep") {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Close preview first
|
|
290
|
+
closePreview();
|
|
291
|
+
|
|
292
|
+
// Open selected file
|
|
293
|
+
if (args.selected_index !== null && grepResults[args.selected_index]) {
|
|
294
|
+
const selected = grepResults[args.selected_index];
|
|
295
|
+
editor.openFile(selected.file, selected.line, selected.column);
|
|
296
|
+
editor.setStatus(`Opened ${selected.file}:${selected.line}`);
|
|
297
|
+
} else {
|
|
298
|
+
editor.setStatus("No file selected");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Clear state
|
|
302
|
+
grepResults = [];
|
|
303
|
+
originalSplitId = null;
|
|
304
|
+
previewSplitId = null;
|
|
305
|
+
|
|
306
|
+
return true;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Handle prompt cancellation
|
|
310
|
+
globalThis.onLiveGrepPromptCancelled = function (args: {
|
|
311
|
+
prompt_type: string;
|
|
312
|
+
}): boolean {
|
|
313
|
+
if (args.prompt_type !== "live-grep") {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Close preview and cleanup
|
|
318
|
+
closePreview();
|
|
319
|
+
grepResults = [];
|
|
320
|
+
originalSplitId = null;
|
|
321
|
+
previewSplitId = null;
|
|
322
|
+
editor.setStatus("Live grep cancelled");
|
|
323
|
+
|
|
324
|
+
return true;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Register event handlers
|
|
328
|
+
editor.on("prompt_changed", "onLiveGrepPromptChanged");
|
|
329
|
+
editor.on("prompt_selection_changed", "onLiveGrepSelectionChanged");
|
|
330
|
+
editor.on("prompt_confirmed", "onLiveGrepPromptConfirmed");
|
|
331
|
+
editor.on("prompt_cancelled", "onLiveGrepPromptCancelled");
|
|
332
|
+
|
|
333
|
+
// Register command
|
|
334
|
+
editor.registerCommand(
|
|
335
|
+
"Live Grep (Find in Files)",
|
|
336
|
+
"Search for text across project with live preview",
|
|
337
|
+
"start_live_grep",
|
|
338
|
+
"normal"
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
editor.debug("Live Grep plugin loaded");
|
|
342
|
+
editor.setStatus("Live Grep ready - use command palette or bind 'start_live_grep'");
|