tabminal 3.0.11 → 3.0.13

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/public/index.html CHANGED
@@ -500,6 +500,29 @@
500
500
  </form>
501
501
  </div>
502
502
  </div>
503
+ <div id="confirm-modal" class="modal" style="display: none;">
504
+ <div class="modal-content confirm-modal-content">
505
+ <h2 id="confirm-modal-title">Confirm</h2>
506
+ <p id="confirm-modal-message"></p>
507
+ <p id="confirm-modal-note" class="confirm-modal-note"></p>
508
+ <div class="confirm-modal-actions">
509
+ <button
510
+ id="confirm-modal-cancel"
511
+ type="button"
512
+ class="secondary-button"
513
+ >
514
+ Cancel
515
+ </button>
516
+ <button
517
+ id="confirm-modal-confirm"
518
+ type="button"
519
+ class="danger-button"
520
+ >
521
+ Delete
522
+ </button>
523
+ </div>
524
+ </div>
525
+ </div>
503
526
  <div id="shortcuts-modal" class="modal" style="display: none;">
504
527
  <div class="modal-content shortcuts-content">
505
528
  <h2>Keyboard Shortcuts</h2>
package/public/styles.css CHANGED
@@ -1156,6 +1156,37 @@ body {
1156
1156
  background-color: rgba(255, 255, 255, 0.05);
1157
1157
  }
1158
1158
 
1159
+ .modal-content button.danger-button {
1160
+ background-color: #dc322f;
1161
+ }
1162
+
1163
+ .modal-content button.danger-button:hover {
1164
+ background-color: #c1272d;
1165
+ }
1166
+
1167
+ .confirm-modal-content {
1168
+ max-width: 420px;
1169
+ text-align: left;
1170
+ }
1171
+
1172
+ .confirm-modal-actions {
1173
+ display: flex;
1174
+ justify-content: flex-end;
1175
+ gap: 0.75rem;
1176
+ margin-top: 1.25rem;
1177
+ }
1178
+
1179
+ .confirm-modal-note {
1180
+ margin: 0.5rem 0 0;
1181
+ color: var(--text-dim);
1182
+ font-size: 0.85rem;
1183
+ }
1184
+
1185
+ .confirm-modal-actions button {
1186
+ width: auto;
1187
+ min-width: 120px;
1188
+ }
1189
+
1159
1190
  .agent-setup-content {
1160
1191
  max-width: 520px;
1161
1192
  text-align: left;
@@ -1471,11 +1502,20 @@ kbd {
1471
1502
  color: var(--text-highlight);
1472
1503
  }
1473
1504
 
1505
+ .file-tree-item.selected {
1506
+ background-color: rgba(88, 110, 117, 0.26);
1507
+ color: var(--text-highlight);
1508
+ }
1509
+
1474
1510
  .file-tree-item.active {
1475
1511
  background-color: rgba(38, 139, 210, 0.15);
1476
1512
  color: var(--accent-color);
1477
1513
  }
1478
1514
 
1515
+ .file-tree-item.selected.active {
1516
+ background-color: rgba(38, 139, 210, 0.22);
1517
+ }
1518
+
1479
1519
  .file-tree-item .icon {
1480
1520
  width: 16px;
1481
1521
  text-align: center;
@@ -1495,6 +1535,81 @@ kbd {
1495
1535
  font-weight: bold;
1496
1536
  }
1497
1537
 
1538
+ .file-tree-name {
1539
+ min-width: 0;
1540
+ }
1541
+
1542
+ .file-tree-rename-btn,
1543
+ .file-tree-delete-btn,
1544
+ .file-tree-new-folder-btn,
1545
+ .file-tree-new-file-btn {
1546
+ display: none;
1547
+ border: 1px solid rgba(38, 139, 210, 0.28);
1548
+ border-radius: 999px;
1549
+ background: rgba(38, 139, 210, 0.12);
1550
+ color: var(--text-highlight);
1551
+ width: 20px;
1552
+ height: 20px;
1553
+ padding: 0;
1554
+ margin: 0;
1555
+ cursor: pointer;
1556
+ }
1557
+
1558
+ .file-tree-item:hover .file-tree-rename-btn,
1559
+ .file-tree-item.selected .file-tree-rename-btn,
1560
+ .file-tree-item:focus-within .file-tree-rename-btn,
1561
+ .file-tree-item:hover .file-tree-delete-btn,
1562
+ .file-tree-item.selected .file-tree-delete-btn,
1563
+ .file-tree-item:focus-within .file-tree-delete-btn {
1564
+ display: inline-flex;
1565
+ align-items: center;
1566
+ justify-content: center;
1567
+ }
1568
+
1569
+ .file-tree-delete-btn {
1570
+ border-color: rgba(220, 50, 47, 0.28);
1571
+ background: rgba(220, 50, 47, 0.12);
1572
+ color: #dc322f;
1573
+ }
1574
+
1575
+ .file-tree-rename-btn:disabled,
1576
+ .file-tree-delete-btn:disabled,
1577
+ .file-tree-new-folder-btn:disabled,
1578
+ .file-tree-new-file-btn:disabled {
1579
+ display: none;
1580
+ }
1581
+
1582
+ .file-tree-create-entry {
1583
+ list-style: none;
1584
+ padding: 0;
1585
+ margin: 0;
1586
+ }
1587
+
1588
+ .file-tree-create-actions {
1589
+ display: flex;
1590
+ align-items: center;
1591
+ gap: 6px;
1592
+ padding: 3px 6px 3px 28px;
1593
+ }
1594
+
1595
+ .file-tree-create-actions .file-tree-new-folder-btn,
1596
+ .file-tree-create-actions .file-tree-new-file-btn {
1597
+ display: inline-flex;
1598
+ align-items: center;
1599
+ justify-content: center;
1600
+ }
1601
+
1602
+ .file-tree-rename-input {
1603
+ min-width: 0;
1604
+ flex: 1 1 auto;
1605
+ padding: 1px 4px;
1606
+ border: 1px solid rgba(38, 139, 210, 0.5);
1607
+ border-radius: 4px;
1608
+ background: rgba(0, 43, 54, 0.9);
1609
+ color: var(--text-highlight);
1610
+ font: inherit;
1611
+ }
1612
+
1498
1613
  .editor-workspace {
1499
1614
  flex-grow: 1;
1500
1615
  display: flex;
@@ -3235,12 +3350,12 @@ kbd {
3235
3350
  align-items: center;
3236
3351
  justify-content: center;
3237
3352
  overflow: auto;
3238
- background-color: #f0f0f0;
3353
+ background-color: #073642;
3239
3354
  background-image:
3240
- linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
3241
- linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
3242
- linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
3243
- linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
3355
+ linear-gradient(45deg, #0b3f4b 25%, transparent 25%),
3356
+ linear-gradient(-45deg, #0b3f4b 25%, transparent 25%),
3357
+ linear-gradient(45deg, transparent 75%, #0b3f4b 75%),
3358
+ linear-gradient(-45deg, transparent 75%, #0b3f4b 75%);
3244
3359
  background-size: 20px 20px;
3245
3360
  background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
3246
3361
  }
package/src/fs-routes.mjs CHANGED
@@ -1,7 +1,150 @@
1
+ import { constants as fsConstants } from 'node:fs';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import process from 'node:process';
4
5
 
6
+ const IMAGE_MIME_TYPES = {
7
+ '.png': 'image/png',
8
+ '.jpg': 'image/jpeg',
9
+ '.jpeg': 'image/jpeg',
10
+ '.gif': 'image/gif',
11
+ '.svg': 'image/svg+xml',
12
+ '.webp': 'image/webp'
13
+ };
14
+
15
+ export function isSupportedTextBuffer(buffer) {
16
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
17
+ return true;
18
+ }
19
+
20
+ let suspiciousControlBytes = 0;
21
+ for (const byte of buffer) {
22
+ if (byte === 0x00) {
23
+ return false;
24
+ }
25
+ if (
26
+ byte < 0x20
27
+ && byte !== 0x09
28
+ && byte !== 0x0a
29
+ && byte !== 0x0d
30
+ ) {
31
+ suspiciousControlBytes += 1;
32
+ }
33
+ }
34
+
35
+ if (suspiciousControlBytes > Math.max(1, buffer.length * 0.01)) {
36
+ return false;
37
+ }
38
+
39
+ try {
40
+ const decoder = new TextDecoder('utf-8', { fatal: true });
41
+ decoder.decode(buffer);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ function joinRelativePath(basePath, name) {
49
+ if (!basePath || basePath === '.' || basePath === path.sep) {
50
+ return name;
51
+ }
52
+ return path.join(basePath, name);
53
+ }
54
+
55
+ async function canWritePath(targetPath) {
56
+ try {
57
+ await fs.access(targetPath, fsConstants.W_OK);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export async function createUniqueChild(baseDir, parentPath, kind) {
65
+ const normalizedParentPath = parentPath || '.';
66
+ const fullParentPath = resolvePath(baseDir, normalizedParentPath);
67
+ const parentStats = await fs.stat(fullParentPath);
68
+ if (!parentStats.isDirectory()) {
69
+ const error = new Error('Parent path is not a directory');
70
+ error.status = 400;
71
+ throw error;
72
+ }
73
+ const writable = await canWritePath(fullParentPath);
74
+ if (!writable) {
75
+ const error = new Error('Parent directory is read-only');
76
+ error.status = 403;
77
+ throw error;
78
+ }
79
+
80
+ const baseName = kind === 'directory'
81
+ ? 'untitled_folder'
82
+ : 'untitled_file';
83
+
84
+ for (let attempt = 0; attempt < 10000; attempt += 1) {
85
+ const name = attempt === 0
86
+ ? baseName
87
+ : `${baseName}_${attempt}`;
88
+ const relativePath = joinRelativePath(normalizedParentPath, name);
89
+ const fullPath = resolvePath(baseDir, relativePath);
90
+ try {
91
+ if (kind === 'directory') {
92
+ await fs.mkdir(fullPath);
93
+ } else {
94
+ const handle = await fs.open(fullPath, 'wx');
95
+ await handle.close();
96
+ }
97
+ return {
98
+ path: relativePath,
99
+ parentPath: normalizedParentPath,
100
+ name,
101
+ isDirectory: kind === 'directory'
102
+ };
103
+ } catch (error) {
104
+ if (error?.code === 'EEXIST') {
105
+ continue;
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ const error = new Error('Unable to find an available name');
112
+ error.status = 409;
113
+ throw error;
114
+ }
115
+
116
+ export async function ensureRenameTargetAvailable(baseDir, sourcePath, newName) {
117
+ const nextPath = path.join(path.dirname(sourcePath), newName);
118
+ const fullSourcePath = resolvePath(baseDir, sourcePath);
119
+ const fullNextPath = resolvePath(baseDir, nextPath);
120
+
121
+ if (fullSourcePath === fullNextPath) {
122
+ return {
123
+ nextPath,
124
+ fullSourcePath,
125
+ fullNextPath
126
+ };
127
+ }
128
+
129
+ try {
130
+ await fs.stat(fullNextPath);
131
+ const error = new Error(
132
+ 'A file or folder with that name already exists.'
133
+ );
134
+ error.status = 409;
135
+ throw error;
136
+ } catch (error) {
137
+ if (error?.code === 'ENOENT') {
138
+ return {
139
+ nextPath,
140
+ fullSourcePath,
141
+ fullNextPath
142
+ };
143
+ }
144
+ throw error;
145
+ }
146
+ }
147
+
5
148
  // Helper to safely resolve path
6
149
  const resolvePath = (baseDir, targetPath) => {
7
150
  return path.resolve(baseDir, targetPath);
@@ -23,28 +166,43 @@ export const setupFsRoutes = (router) => {
23
166
  return;
24
167
  }
25
168
 
169
+ let renameable = false;
170
+ try {
171
+ await fs.access(fullPath, fsConstants.W_OK);
172
+ renameable = true;
173
+ } catch {
174
+ renameable = false;
175
+ }
176
+
26
177
  const dirents = await fs.readdir(fullPath, { withFileTypes: true });
27
178
 
28
- const files = dirents
29
- .filter(dirent => dirent.name !== '.DS_Store')
30
- .map(dirent => {
31
- return {
32
- name: dirent.name,
33
- isDirectory: dirent.isDirectory(),
34
- path: path.join(dirPath, dirent.name),
35
- // Add basic icon/type hint logic here if needed later
36
- };
37
- });
179
+ const items = await Promise.all(
180
+ dirents
181
+ .filter(dirent => dirent.name !== '.DS_Store')
182
+ .map(async (dirent) => {
183
+ const entryPath = path.join(dirPath, dirent.name);
184
+ return {
185
+ name: dirent.name,
186
+ isDirectory: dirent.isDirectory(),
187
+ path: entryPath,
188
+ renameable,
189
+ deleteable: renameable
190
+ };
191
+ })
192
+ );
38
193
 
39
194
  // Sort: Directories first, then files
40
- files.sort((a, b) => {
195
+ items.sort((a, b) => {
41
196
  if (a.isDirectory === b.isDirectory) {
42
197
  return a.name.localeCompare(b.name);
43
198
  }
44
199
  return a.isDirectory ? -1 : 1;
45
200
  });
46
201
 
47
- ctx.body = files;
202
+ ctx.body = {
203
+ items,
204
+ creatable: renameable
205
+ };
48
206
  } catch (err) {
49
207
  console.error('FS List Error:', err);
50
208
  ctx.status = 500;
@@ -52,6 +210,110 @@ export const setupFsRoutes = (router) => {
52
210
  }
53
211
  });
54
212
 
213
+ router.post('/api/fs/rename', async (ctx) => {
214
+ const sourcePath = ctx.request.body?.path;
215
+ const newName = ctx.request.body?.newName;
216
+ if (typeof sourcePath !== 'string' || sourcePath.length === 0) {
217
+ ctx.status = 400;
218
+ ctx.body = { error: 'Path required' };
219
+ return;
220
+ }
221
+ if (typeof newName !== 'string' || newName.length === 0) {
222
+ ctx.status = 400;
223
+ ctx.body = { error: 'New name required' };
224
+ return;
225
+ }
226
+ if (newName === '.' || newName === '..') {
227
+ ctx.status = 400;
228
+ ctx.body = { error: 'Invalid name' };
229
+ return;
230
+ }
231
+ if (/[\\/]/.test(newName)) {
232
+ ctx.status = 400;
233
+ ctx.body = { error: 'Name must not contain path separators' };
234
+ return;
235
+ }
236
+
237
+ try {
238
+ const {
239
+ nextPath,
240
+ fullSourcePath,
241
+ fullNextPath
242
+ } = await ensureRenameTargetAvailable(baseDir, sourcePath, newName);
243
+ const stats = await fs.stat(fullSourcePath);
244
+
245
+ if (fullSourcePath !== fullNextPath) {
246
+ await fs.rename(fullSourcePath, fullNextPath);
247
+ }
248
+
249
+ ctx.body = {
250
+ path: sourcePath,
251
+ newPath: nextPath,
252
+ isDirectory: stats.isDirectory()
253
+ };
254
+ } catch (err) {
255
+ console.error('FS Rename Error:', err);
256
+ ctx.status = err?.status || (err?.code === 'EEXIST' ? 409 : 500);
257
+ ctx.body = { error: err.message };
258
+ }
259
+ });
260
+
261
+ router.post('/api/fs/delete', async (ctx) => {
262
+ const targetPath = ctx.request.body?.path;
263
+ if (typeof targetPath !== 'string' || targetPath.length === 0) {
264
+ ctx.status = 400;
265
+ ctx.body = { error: 'Path required' };
266
+ return;
267
+ }
268
+
269
+ try {
270
+ const fullTargetPath = resolvePath(baseDir, targetPath);
271
+ const stats = await fs.stat(fullTargetPath);
272
+ await fs.rm(fullTargetPath, {
273
+ recursive: stats.isDirectory(),
274
+ force: false
275
+ });
276
+
277
+ ctx.body = {
278
+ path: targetPath,
279
+ isDirectory: stats.isDirectory()
280
+ };
281
+ } catch (err) {
282
+ console.error('FS Delete Error:', err);
283
+ ctx.status = 500;
284
+ ctx.body = { error: err.message };
285
+ }
286
+ });
287
+
288
+ router.post('/api/fs/create', async (ctx) => {
289
+ const parentPath = ctx.request.body?.parentPath;
290
+ const kind = ctx.request.body?.kind;
291
+
292
+ if (typeof parentPath !== 'string' || parentPath.length === 0) {
293
+ ctx.status = 400;
294
+ ctx.body = { error: 'Parent path required' };
295
+ return;
296
+ }
297
+ if (kind !== 'file' && kind !== 'directory') {
298
+ ctx.status = 400;
299
+ ctx.body = { error: 'Invalid create kind' };
300
+ return;
301
+ }
302
+
303
+ try {
304
+ const created = await createUniqueChild(
305
+ baseDir,
306
+ parentPath,
307
+ kind
308
+ );
309
+ ctx.body = created;
310
+ } catch (err) {
311
+ console.error('FS Create Error:', err);
312
+ ctx.status = err?.status || 500;
313
+ ctx.body = { error: err.message };
314
+ }
315
+ });
316
+
55
317
  // Read file
56
318
  router.get('/api/fs/read', async (ctx) => {
57
319
  const filePath = ctx.query.path;
@@ -65,13 +327,30 @@ export const setupFsRoutes = (router) => {
65
327
  const fullPath = resolvePath(baseDir, filePath);
66
328
  const stats = await fs.stat(fullPath);
67
329
 
330
+ if (!stats.isFile()) {
331
+ ctx.status = 400;
332
+ ctx.body = { error: 'Not a file' };
333
+ return;
334
+ }
335
+
68
336
  if (stats.size > 1024 * 1024 * 5) { // 5MB limit for now
69
337
  ctx.status = 400;
70
338
  ctx.body = { error: 'File too large' };
71
339
  return;
72
340
  }
73
341
 
74
- const content = await fs.readFile(fullPath, 'utf-8');
342
+ const contentBuffer = await fs.readFile(fullPath);
343
+ if (!isSupportedTextBuffer(contentBuffer)) {
344
+ ctx.status = 415;
345
+ ctx.body = {
346
+ error: 'Unsupported file type',
347
+ code: 'unsupported-file-type'
348
+ };
349
+ return;
350
+ }
351
+
352
+ const decoder = new TextDecoder('utf-8', { fatal: true });
353
+ const content = decoder.decode(contentBuffer);
75
354
 
76
355
  let readonly = false;
77
356
  try {
@@ -99,19 +378,10 @@ export const setupFsRoutes = (router) => {
99
378
 
100
379
  try {
101
380
  const fullPath = resolvePath(baseDir, filePath);
102
- // Basic mime type handling could be added here
103
381
  const ext = path.extname(fullPath).toLowerCase();
104
- const mimeTypes = {
105
- '.png': 'image/png',
106
- '.jpg': 'image/jpeg',
107
- '.jpeg': 'image/jpeg',
108
- '.gif': 'image/gif',
109
- '.svg': 'image/svg+xml',
110
- '.webp': 'image/webp'
111
- };
112
382
 
113
- if (mimeTypes[ext]) {
114
- ctx.type = mimeTypes[ext];
383
+ if (IMAGE_MIME_TYPES[ext]) {
384
+ ctx.type = IMAGE_MIME_TYPES[ext];
115
385
  ctx.body = await fs.readFile(fullPath);
116
386
  } else {
117
387
  ctx.status = 400;