pi-tau-web-server 1.0.8
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 +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- package/tsconfig.server.json +16 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/public/app.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './app-main.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function setupCommandPalette(commands) {
|
|
2
|
+
// These elements are part of the app's static index.html shell, so they
|
|
3
|
+
// are present when this setup function runs; assert non-null at the query.
|
|
4
|
+
const commandBtn = document.getElementById('command-btn');
|
|
5
|
+
const commandPalette = document.getElementById('command-palette');
|
|
6
|
+
const commandPaletteOverlay = document.getElementById('command-palette-overlay');
|
|
7
|
+
const commandList = document.getElementById('command-list');
|
|
8
|
+
function open() {
|
|
9
|
+
commandList.innerHTML = '';
|
|
10
|
+
commands.forEach(cmd => {
|
|
11
|
+
const el = document.createElement('div');
|
|
12
|
+
el.className = 'command-item';
|
|
13
|
+
el.innerHTML = `
|
|
14
|
+
<div class="command-icon">${cmd.icon}</div>
|
|
15
|
+
<div>
|
|
16
|
+
<div class="command-label">${cmd.label}</div>
|
|
17
|
+
<div class="command-desc">${cmd.desc}</div>
|
|
18
|
+
</div>
|
|
19
|
+
`;
|
|
20
|
+
el.addEventListener('click', () => {
|
|
21
|
+
close();
|
|
22
|
+
cmd.action();
|
|
23
|
+
});
|
|
24
|
+
commandList.appendChild(el);
|
|
25
|
+
});
|
|
26
|
+
commandPalette.classList.remove('hidden');
|
|
27
|
+
commandPaletteOverlay.classList.remove('hidden');
|
|
28
|
+
}
|
|
29
|
+
function close() {
|
|
30
|
+
commandPalette.classList.add('hidden');
|
|
31
|
+
commandPaletteOverlay.classList.add('hidden');
|
|
32
|
+
}
|
|
33
|
+
function closeIfOpen() {
|
|
34
|
+
if (commandPalette.classList.contains('hidden'))
|
|
35
|
+
return false;
|
|
36
|
+
close();
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
commandBtn.addEventListener('click', open);
|
|
40
|
+
commandPaletteOverlay.addEventListener('click', close);
|
|
41
|
+
return { close, closeIfOpen };
|
|
42
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialogs - Handles extension UI dialogs
|
|
3
|
+
*/
|
|
4
|
+
export class DialogHandler {
|
|
5
|
+
container;
|
|
6
|
+
wsClient;
|
|
7
|
+
getSessionId;
|
|
8
|
+
currentDialog;
|
|
9
|
+
currentRequest;
|
|
10
|
+
timeoutId;
|
|
11
|
+
onIdle;
|
|
12
|
+
constructor(container, wsClient, getSessionId = null) {
|
|
13
|
+
this.container = container;
|
|
14
|
+
this.wsClient = wsClient;
|
|
15
|
+
this.getSessionId = getSessionId;
|
|
16
|
+
this.currentDialog = null;
|
|
17
|
+
this.currentRequest = null;
|
|
18
|
+
this.timeoutId = null;
|
|
19
|
+
this.onIdle = null;
|
|
20
|
+
}
|
|
21
|
+
showSelect(request) {
|
|
22
|
+
this.cancelCurrentDialog(true);
|
|
23
|
+
const { id, title, options, timeout, sessionId } = request;
|
|
24
|
+
const dialog = document.createElement('div');
|
|
25
|
+
dialog.className = 'dialog';
|
|
26
|
+
dialog.innerHTML = `
|
|
27
|
+
<div class="dialog-title">${this.escapeHtml(title || 'Select an option')}</div>
|
|
28
|
+
<div class="dialog-options" id="dialog-options"></div>
|
|
29
|
+
<div class="dialog-actions">
|
|
30
|
+
<button id="dialog-cancel">Cancel</button>
|
|
31
|
+
</div>
|
|
32
|
+
`;
|
|
33
|
+
const optionsContainer = dialog.querySelector('#dialog-options');
|
|
34
|
+
(options || []).forEach((option) => {
|
|
35
|
+
const optionDiv = document.createElement('div');
|
|
36
|
+
optionDiv.className = 'dialog-option';
|
|
37
|
+
optionDiv.textContent = option;
|
|
38
|
+
optionDiv.onclick = () => {
|
|
39
|
+
this.respond(id, { value: option }, sessionId);
|
|
40
|
+
};
|
|
41
|
+
optionsContainer.appendChild(optionDiv);
|
|
42
|
+
});
|
|
43
|
+
dialog.querySelector('#dialog-cancel').onclick = () => {
|
|
44
|
+
this.respond(id, { cancelled: true }, sessionId);
|
|
45
|
+
};
|
|
46
|
+
this.showDialog(dialog, timeout, id, sessionId, request);
|
|
47
|
+
}
|
|
48
|
+
showConfirm(request) {
|
|
49
|
+
this.cancelCurrentDialog(true);
|
|
50
|
+
const { id, title, message, timeout, sessionId } = request;
|
|
51
|
+
const dialog = document.createElement('div');
|
|
52
|
+
dialog.className = 'dialog';
|
|
53
|
+
dialog.innerHTML = `
|
|
54
|
+
<div class="dialog-title">${this.escapeHtml(title || 'Confirm')}</div>
|
|
55
|
+
${message ? `<div class="dialog-message">${this.escapeHtml(message)}</div>` : ''}
|
|
56
|
+
<div class="dialog-actions">
|
|
57
|
+
<button id="dialog-no">No</button>
|
|
58
|
+
<button id="dialog-yes">Yes</button>
|
|
59
|
+
</div>
|
|
60
|
+
`;
|
|
61
|
+
dialog.querySelector('#dialog-yes').onclick = () => {
|
|
62
|
+
this.respond(id, { confirmed: true }, sessionId);
|
|
63
|
+
};
|
|
64
|
+
dialog.querySelector('#dialog-no').onclick = () => {
|
|
65
|
+
this.respond(id, { confirmed: false }, sessionId);
|
|
66
|
+
};
|
|
67
|
+
this.showDialog(dialog, timeout, id, sessionId, request);
|
|
68
|
+
}
|
|
69
|
+
showInput(request) {
|
|
70
|
+
this.cancelCurrentDialog(true);
|
|
71
|
+
const { id, title, placeholder, timeout, sessionId } = request;
|
|
72
|
+
const dialog = document.createElement('div');
|
|
73
|
+
dialog.className = 'dialog';
|
|
74
|
+
dialog.innerHTML = `
|
|
75
|
+
<div class="dialog-title">${this.escapeHtml(title || 'Input')}</div>
|
|
76
|
+
<input type="text" class="dialog-input" id="dialog-input" placeholder="${this.escapeHtml(placeholder || '')}" />
|
|
77
|
+
<div class="dialog-actions">
|
|
78
|
+
<button id="dialog-cancel">Cancel</button>
|
|
79
|
+
<button id="dialog-submit">Submit</button>
|
|
80
|
+
</div>
|
|
81
|
+
`;
|
|
82
|
+
const input = dialog.querySelector('#dialog-input');
|
|
83
|
+
if (!input)
|
|
84
|
+
return;
|
|
85
|
+
const submit = () => {
|
|
86
|
+
const value = input.value.trim();
|
|
87
|
+
this.respond(id, value ? { value } : { cancelled: true }, sessionId);
|
|
88
|
+
};
|
|
89
|
+
input.addEventListener('keypress', (e) => {
|
|
90
|
+
if (e.key === 'Enter')
|
|
91
|
+
submit();
|
|
92
|
+
});
|
|
93
|
+
dialog.querySelector('#dialog-submit').onclick = submit;
|
|
94
|
+
dialog.querySelector('#dialog-cancel').onclick = () => {
|
|
95
|
+
this.respond(id, { cancelled: true }, sessionId);
|
|
96
|
+
};
|
|
97
|
+
this.showDialog(dialog, timeout, id, sessionId, request);
|
|
98
|
+
// Focus input after a short delay
|
|
99
|
+
setTimeout(() => input.focus(), 100);
|
|
100
|
+
}
|
|
101
|
+
showEditor(request) {
|
|
102
|
+
this.cancelCurrentDialog(true);
|
|
103
|
+
const { id, title, prefill, timeout, sessionId } = request;
|
|
104
|
+
const dialog = document.createElement('div');
|
|
105
|
+
dialog.className = 'dialog';
|
|
106
|
+
dialog.innerHTML = `
|
|
107
|
+
<div class="dialog-title">${this.escapeHtml(title || 'Editor')}</div>
|
|
108
|
+
<textarea class="dialog-textarea" id="dialog-textarea">${this.escapeHtml(prefill || '')}</textarea>
|
|
109
|
+
<div class="dialog-actions">
|
|
110
|
+
<button id="dialog-cancel">Cancel</button>
|
|
111
|
+
<button id="dialog-save">Save</button>
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
const textarea = dialog.querySelector('#dialog-textarea');
|
|
115
|
+
if (!textarea)
|
|
116
|
+
return;
|
|
117
|
+
dialog.querySelector('#dialog-save').onclick = () => {
|
|
118
|
+
const value = textarea.value;
|
|
119
|
+
this.respond(id, value ? { value } : { cancelled: true }, sessionId);
|
|
120
|
+
};
|
|
121
|
+
dialog.querySelector('#dialog-cancel').onclick = () => {
|
|
122
|
+
this.respond(id, { cancelled: true }, sessionId);
|
|
123
|
+
};
|
|
124
|
+
this.showDialog(dialog, timeout, id, sessionId, request);
|
|
125
|
+
// Focus textarea after a short delay
|
|
126
|
+
setTimeout(() => textarea.focus(), 100);
|
|
127
|
+
}
|
|
128
|
+
showNotification(request) {
|
|
129
|
+
const { message, notifyType } = request;
|
|
130
|
+
// Create a temporary notification element
|
|
131
|
+
const notification = document.createElement('div');
|
|
132
|
+
notification.className = 'error-message';
|
|
133
|
+
notification.textContent = `${notifyType === 'error' ? '⚠️' : notifyType === 'warning' ? '⚠️' : 'ℹ️'} ${message}`;
|
|
134
|
+
// Add to messages container temporarily
|
|
135
|
+
const messagesContainer = document.getElementById('messages');
|
|
136
|
+
if (messagesContainer) {
|
|
137
|
+
messagesContainer.appendChild(notification);
|
|
138
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
139
|
+
// Remove after 5 seconds
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
notification.remove();
|
|
142
|
+
}, 5000);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
showDialog(dialogElement, timeout, requestId, sessionId = null, request = null) {
|
|
146
|
+
this.currentDialog = dialogElement;
|
|
147
|
+
this.currentRequest = { id: requestId, sessionId, request };
|
|
148
|
+
this.container.innerHTML = '';
|
|
149
|
+
this.container.appendChild(dialogElement);
|
|
150
|
+
this.container.classList.remove('hidden');
|
|
151
|
+
// Set up timeout if specified
|
|
152
|
+
if (timeout) {
|
|
153
|
+
this.timeoutId = setTimeout(() => {
|
|
154
|
+
this.respond(requestId, { cancelled: true }, sessionId);
|
|
155
|
+
}, timeout);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
cancelCurrentDialog(suppressIdle = false) {
|
|
159
|
+
if (!this.currentRequest?.id) {
|
|
160
|
+
this.clearCurrentDialog();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const { id, sessionId } = this.currentRequest;
|
|
164
|
+
this.clearCurrentDialog();
|
|
165
|
+
this.wsClient.send({
|
|
166
|
+
type: 'extension_ui_response',
|
|
167
|
+
id,
|
|
168
|
+
sessionId: sessionId || this.getSessionId?.(),
|
|
169
|
+
cancelled: true,
|
|
170
|
+
});
|
|
171
|
+
if (!suppressIdle)
|
|
172
|
+
this.onIdle?.();
|
|
173
|
+
}
|
|
174
|
+
clearCurrentDialog() {
|
|
175
|
+
if (this.timeoutId) {
|
|
176
|
+
clearTimeout(this.timeoutId);
|
|
177
|
+
this.timeoutId = null;
|
|
178
|
+
}
|
|
179
|
+
this.container.innerHTML = '';
|
|
180
|
+
this.container.classList.add('hidden');
|
|
181
|
+
this.currentDialog = null;
|
|
182
|
+
this.currentRequest = null;
|
|
183
|
+
}
|
|
184
|
+
respond(id, response, sessionId = null) {
|
|
185
|
+
this.clearCurrentDialog();
|
|
186
|
+
this.wsClient.send({
|
|
187
|
+
type: 'extension_ui_response',
|
|
188
|
+
id,
|
|
189
|
+
sessionId: sessionId || this.getSessionId?.(),
|
|
190
|
+
...response
|
|
191
|
+
});
|
|
192
|
+
this.onIdle?.();
|
|
193
|
+
}
|
|
194
|
+
escapeHtml(text) {
|
|
195
|
+
const div = document.createElement('div');
|
|
196
|
+
div.textContent = text;
|
|
197
|
+
return div.innerHTML;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Browser — right sidebar file tree with drag-and-drop
|
|
3
|
+
*/
|
|
4
|
+
const FILE_ICONS = {
|
|
5
|
+
// Folders
|
|
6
|
+
directory: '📁',
|
|
7
|
+
// Code
|
|
8
|
+
js: '📄', ts: '📄', jsx: '📄', tsx: '📄',
|
|
9
|
+
py: '🐍', rb: '💎', go: '📄', rs: '🦀',
|
|
10
|
+
// Web
|
|
11
|
+
html: '🌐', css: '🎨', svg: '🎨',
|
|
12
|
+
// Data
|
|
13
|
+
json: '📋', yaml: '📋', yml: '📋', toml: '📋',
|
|
14
|
+
xml: '📋', csv: '📋',
|
|
15
|
+
// Docs
|
|
16
|
+
md: '📝', txt: '📝', rst: '📝',
|
|
17
|
+
// Images
|
|
18
|
+
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️',
|
|
19
|
+
webp: '🖼️', ico: '🖼️',
|
|
20
|
+
// Config
|
|
21
|
+
env: '🔒', gitignore: '🔒', lock: '🔒',
|
|
22
|
+
// Default
|
|
23
|
+
default: '📄',
|
|
24
|
+
};
|
|
25
|
+
export function getFileIcon(name, isDirectory) {
|
|
26
|
+
if (isDirectory)
|
|
27
|
+
return FILE_ICONS.directory;
|
|
28
|
+
const ext = name.split('.').pop()?.toLowerCase() || '';
|
|
29
|
+
return FILE_ICONS[ext] || FILE_ICONS.default;
|
|
30
|
+
}
|
|
31
|
+
function formatSize(bytes) {
|
|
32
|
+
if (bytes == null)
|
|
33
|
+
return '';
|
|
34
|
+
if (bytes < 1024)
|
|
35
|
+
return `${bytes}B`;
|
|
36
|
+
if (bytes < 1024 * 1024)
|
|
37
|
+
return `${(bytes / 1024).toFixed(0)}K`;
|
|
38
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
39
|
+
}
|
|
40
|
+
export class FileBrowser {
|
|
41
|
+
container;
|
|
42
|
+
pathEl;
|
|
43
|
+
messageInput;
|
|
44
|
+
onFileInserted;
|
|
45
|
+
getSessionId;
|
|
46
|
+
currentPath;
|
|
47
|
+
constructor(container, pathEl, messageInput, onFileInserted = null, getSessionId = null) {
|
|
48
|
+
this.container = container;
|
|
49
|
+
this.pathEl = pathEl;
|
|
50
|
+
this.messageInput = messageInput;
|
|
51
|
+
this.onFileInserted = onFileInserted;
|
|
52
|
+
this.getSessionId = getSessionId;
|
|
53
|
+
this.currentPath = null;
|
|
54
|
+
this.setupDropTarget();
|
|
55
|
+
}
|
|
56
|
+
async load(dirPath) {
|
|
57
|
+
this.container.innerHTML = '<div class="file-loading">Loading…</div>';
|
|
58
|
+
try {
|
|
59
|
+
const params = new URLSearchParams();
|
|
60
|
+
const sessionId = this.getSessionId?.();
|
|
61
|
+
if (!sessionId) {
|
|
62
|
+
this.currentPath = null;
|
|
63
|
+
this.pathEl.textContent = '';
|
|
64
|
+
this.pathEl.title = '';
|
|
65
|
+
this.container.innerHTML = '<div class="file-loading">Select a Tau tab to browse files</div>';
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
params.set('sessionId', sessionId);
|
|
69
|
+
if (dirPath)
|
|
70
|
+
params.set('path', dirPath);
|
|
71
|
+
const qs = params.toString();
|
|
72
|
+
const url = qs ? `/api/files?${qs}` : '/api/files';
|
|
73
|
+
const res = await fetch(url);
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
if (data.error) {
|
|
76
|
+
this.container.innerHTML = `<div class="file-loading">${data.error}</div>`;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.currentPath = data.path;
|
|
80
|
+
this.pathEl.textContent = data.path;
|
|
81
|
+
this.pathEl.title = data.path;
|
|
82
|
+
this.render(data.items);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
this.container.innerHTML = '<div class="file-loading">Failed to load</div>';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
getParentPath() {
|
|
89
|
+
if (!this.currentPath)
|
|
90
|
+
return null;
|
|
91
|
+
const sep = this.currentPath.includes('\\') ? '\\' : '/';
|
|
92
|
+
const normalized = this.currentPath.endsWith(sep) ? this.currentPath.slice(0, -1) : this.currentPath;
|
|
93
|
+
const lastSep = normalized.lastIndexOf(sep);
|
|
94
|
+
if (lastSep <= 0)
|
|
95
|
+
return sep === '/' ? '/' : null;
|
|
96
|
+
const parent = normalized.slice(0, lastSep);
|
|
97
|
+
return /^[A-Za-z]:$/.test(parent) ? parent + sep : parent;
|
|
98
|
+
}
|
|
99
|
+
render(items) {
|
|
100
|
+
this.container.innerHTML = '';
|
|
101
|
+
if (items.length === 0) {
|
|
102
|
+
this.container.innerHTML = '<div class="file-loading">Empty directory</div>';
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
const el = document.createElement('div');
|
|
107
|
+
el.className = `file-item${item.isDirectory ? ' directory' : ''}`;
|
|
108
|
+
el.draggable = true;
|
|
109
|
+
el.dataset.path = item.path;
|
|
110
|
+
el.dataset.name = item.name;
|
|
111
|
+
el.dataset.isDirectory = String(item.isDirectory);
|
|
112
|
+
const icon = getFileIcon(item.name, item.isDirectory);
|
|
113
|
+
const size = item.isDirectory ? '' : formatSize(item.size);
|
|
114
|
+
el.innerHTML = `
|
|
115
|
+
<span class="file-icon">${icon}</span>
|
|
116
|
+
<span class="file-name" title="${item.name}">${item.name}</span>
|
|
117
|
+
${size ? `<span class="file-size">${size}</span>` : ''}
|
|
118
|
+
`;
|
|
119
|
+
// Click: navigate directory or insert file path into input
|
|
120
|
+
el.addEventListener('click', () => {
|
|
121
|
+
if (item.isDirectory) {
|
|
122
|
+
this.load(item.path);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
this.insertPath(item.path);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// Double-click: open file natively
|
|
129
|
+
el.addEventListener('dblclick', (e) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
if (!item.isDirectory) {
|
|
132
|
+
this.openNatively(item.path);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
// Drag start
|
|
136
|
+
el.addEventListener('dragstart', (e) => {
|
|
137
|
+
if (!e.dataTransfer)
|
|
138
|
+
return;
|
|
139
|
+
e.dataTransfer.setData('text/plain', item.path);
|
|
140
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
141
|
+
el.classList.add('dragging');
|
|
142
|
+
});
|
|
143
|
+
el.addEventListener('dragend', () => {
|
|
144
|
+
el.classList.remove('dragging');
|
|
145
|
+
});
|
|
146
|
+
this.container.appendChild(el);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async openNatively(filePath) {
|
|
150
|
+
try {
|
|
151
|
+
const sessionId = this.getSessionId?.();
|
|
152
|
+
if (!sessionId)
|
|
153
|
+
return;
|
|
154
|
+
await fetch('/api/open', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({ filePath, sessionId }),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error('[FileBrowser] Failed to open:', err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
insertPath(filePath) {
|
|
165
|
+
const input = this.messageInput;
|
|
166
|
+
const start = input.selectionStart ?? 0;
|
|
167
|
+
const end = input.selectionEnd ?? 0;
|
|
168
|
+
input.value = input.value.substring(0, start) + filePath + ' ' + input.value.substring(end);
|
|
169
|
+
input.selectionStart = input.selectionEnd = start + filePath.length + 1;
|
|
170
|
+
input.focus();
|
|
171
|
+
input.dispatchEvent(new Event('input'));
|
|
172
|
+
if (this.onFileInserted)
|
|
173
|
+
this.onFileInserted(filePath);
|
|
174
|
+
}
|
|
175
|
+
setupDropTarget() {
|
|
176
|
+
const input = this.messageInput;
|
|
177
|
+
input.addEventListener('dragover', (e) => {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
if (e.dataTransfer)
|
|
180
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
181
|
+
input.classList.add('file-drop-hover');
|
|
182
|
+
});
|
|
183
|
+
input.addEventListener('dragleave', () => {
|
|
184
|
+
input.classList.remove('file-drop-hover');
|
|
185
|
+
});
|
|
186
|
+
input.addEventListener('drop', (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
input.classList.remove('file-drop-hover');
|
|
189
|
+
const filePath = e.dataTransfer?.getData('text/plain') ?? '';
|
|
190
|
+
// Accept Unix paths (/) and Windows paths (C:\ or C:/)
|
|
191
|
+
if (filePath && (filePath.startsWith('/') || /^[A-Za-z]:[\\\/]/.test(filePath))) {
|
|
192
|
+
this.insertPath(filePath);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" fill="#f0efe9"/>
|
|
3
|
+
<!-- Grid: 8x8, each cell 40px, centered in 512 (offset: 96,96) -->
|
|
4
|
+
<!-- Top bar: 3 cells wide at row 2, cols 2-4 -->
|
|
5
|
+
<rect x="176" y="156" width="56" height="56" fill="#2d2d2d"/>
|
|
6
|
+
<rect x="232" y="156" width="56" height="56" fill="#2d2d2d"/>
|
|
7
|
+
<rect x="288" y="156" width="56" height="56" fill="#2d2d2d"/>
|
|
8
|
+
<!-- Stem: 1 cell wide at col 3, rows 3-4 -->
|
|
9
|
+
<rect x="232" y="212" width="56" height="56" fill="#2d2d2d"/>
|
|
10
|
+
<rect x="232" y="268" width="56" height="56" fill="#2d2d2d"/>
|
|
11
|
+
<!-- Curl: offset block at col 4, row 5 -->
|
|
12
|
+
<rect x="288" y="324" width="56" height="56" fill="#2d2d2d"/>
|
|
13
|
+
</svg>
|
|
Binary file
|
|
Binary file
|