u
This commit is contained in:
@@ -1944,6 +1944,85 @@ export const modelconversation = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const vfs = {
|
||||||
|
/**
|
||||||
|
* GET /api/vfs
|
||||||
|
* List files in the virtual filesystem for the selected company.
|
||||||
|
* → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] }
|
||||||
|
*/
|
||||||
|
list() {
|
||||||
|
return http('/api/vfs');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/vfs/*path
|
||||||
|
* Fetch raw content of a file (e.g. text for the editor).
|
||||||
|
* @param {string} path
|
||||||
|
*/
|
||||||
|
async getText(path) {
|
||||||
|
const clean = String(path || '').replace(/^\/+/, '');
|
||||||
|
const res = await fetch(`/api/vfs/${clean}?raw=1`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/vfs/*path
|
||||||
|
* Create or update a file.
|
||||||
|
* @param {string} path
|
||||||
|
* @param {string} content
|
||||||
|
* @param {string} [mime]
|
||||||
|
*/
|
||||||
|
save(path, content, mime) {
|
||||||
|
const clean = String(path || '').replace(/^\/+/, '');
|
||||||
|
return http(`/api/vfs/${clean}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { content, mime },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/vfs/*path
|
||||||
|
* Delete a file.
|
||||||
|
* @param {string} path
|
||||||
|
*/
|
||||||
|
delete(path) {
|
||||||
|
const clean = String(path || '').replace(/^\/+/, '');
|
||||||
|
return http(`/api/vfs/${clean}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vfs/upload
|
||||||
|
* Upload multiple files or a single file to VFS.
|
||||||
|
* @param {File[]|FileList|File} files
|
||||||
|
* @param {string} [targetDir]
|
||||||
|
*/
|
||||||
|
async upload(files, targetDir = '') {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (targetDir) formData.append('targetDir', targetDir);
|
||||||
|
const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files];
|
||||||
|
for (const f of list) {
|
||||||
|
formData.append('files', f);
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/vfs/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
auth,
|
auth,
|
||||||
article,
|
article,
|
||||||
@@ -1968,8 +2047,10 @@ export default {
|
|||||||
supplier,
|
supplier,
|
||||||
thumb,
|
thumb,
|
||||||
unit,
|
unit,
|
||||||
|
vfs,
|
||||||
warehouse,
|
warehouse,
|
||||||
stock,
|
stock,
|
||||||
globalHistory,
|
globalHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ export {
|
|||||||
selectHasPendingAsk,
|
selectHasPendingAsk,
|
||||||
} from './chatAutomat';
|
} from './chatAutomat';
|
||||||
|
|
||||||
|
export { default as vfsAutomat } from './vfsAutomat';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* Overview of Automats in Dashfront
|
* Overview of Automats in Dashfront
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function initPwa() {
|
|||||||
onRegistered(registration) {
|
onRegistered(registration) {
|
||||||
if (!registration) return;
|
if (!registration) return;
|
||||||
|
|
||||||
// Check for new updates periodically (every 30 minutes)
|
// Check for new updates periodically (every 1 minutes)
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
registration.update().catch(() => { });
|
registration.update().catch(() => { });
|
||||||
}, 1 * 60 * 1000);
|
}, 1 * 60 * 1000);
|
||||||
|
|||||||
376
src/automats/vfsAutomat.js
Normal file
376
src/automats/vfsAutomat.js
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* @file vfsAutomat.js
|
||||||
|
* @description State container for the VFS (Virtual Filesystem) explorer and editor,
|
||||||
|
* integrated side-by-side with the AI assistant chat drawer.
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes to auth state changes — loads file listing on
|
||||||
|
* `'authed'` with a `selectedCompanyId`, and calls `actions.reset()` on `'anon'`.
|
||||||
|
* - `sseAutomat`: Dispatches `'vfs'` SSE mutation events to silently refresh file listing.
|
||||||
|
* - UI / ChatDrawer: Actions (`toggleVfs`, `selectFile`, `saveFile`, `uploadFiles`, `deleteFile`, `createFile`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { vfs as vfsApi } from '../api';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} VfsFile
|
||||||
|
* @property {string} name Relative path in VFS (e.g. `artikelpreise.csv`, `attachments/test.pdf`)
|
||||||
|
* @property {number} size File size in bytes
|
||||||
|
* @property {string} mime Detected MIME type
|
||||||
|
* @property {string} updated_at ISO timestamp
|
||||||
|
* @property {string} created_at ISO timestamp
|
||||||
|
* @property {boolean} isText Whether the file can be edited in the text editor
|
||||||
|
* @property {boolean} isImage Whether the file can be rendered as an image
|
||||||
|
* @property {boolean} isPdf Whether the file is a PDF
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} VfsState
|
||||||
|
* @property {boolean} isOpen Whether VFS editor panel is open side-by-side with chat
|
||||||
|
* @property {VfsFile[]} files List of files in the current company's VFS
|
||||||
|
* @property {boolean} loadingFiles
|
||||||
|
* @property {string|null} loadError
|
||||||
|
* @property {string|null} selectedFileName Currently selected file path
|
||||||
|
* @property {VfsFile|null} selectedFile Metadata of the selected file
|
||||||
|
* @property {string} fileContent Current text content in editor
|
||||||
|
* @property {string} originalContent Content as saved on server (for dirty tracking)
|
||||||
|
* @property {boolean} isDirty True if user made uncommitted changes
|
||||||
|
* @property {boolean} loadingContent True while fetching text file content
|
||||||
|
* @property {boolean} isSaving
|
||||||
|
* @property {string|null} saveError
|
||||||
|
* @property {boolean} isUploading
|
||||||
|
* @property {string|null} uploadError
|
||||||
|
* @property {string} searchQuery Current filter query in file explorer
|
||||||
|
* @property {number} vfsWidth Width of the VFS panel in pixels
|
||||||
|
* @property {boolean} newFileDialogOpen
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {VfsState} */
|
||||||
|
const initialState = {
|
||||||
|
isOpen: false,
|
||||||
|
files: [],
|
||||||
|
loadingFiles: false,
|
||||||
|
loadError: null,
|
||||||
|
selectedFileName: null,
|
||||||
|
selectedFile: null,
|
||||||
|
fileContent: '',
|
||||||
|
originalContent: '',
|
||||||
|
isDirty: false,
|
||||||
|
loadingContent: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
isUploading: false,
|
||||||
|
uploadError: null,
|
||||||
|
searchQuery: '',
|
||||||
|
vfsWidth: 480,
|
||||||
|
newFileDialogOpen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let vfsAutomat;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Toggle or set the open state of the VFS panel in the chat drawer.
|
||||||
|
* @param {boolean} [nextOpen]
|
||||||
|
*/
|
||||||
|
toggleVfs(nextOpen) {
|
||||||
|
const willOpen = typeof nextOpen === 'boolean' ? nextOpen : !vfsAutomat.state.isOpen;
|
||||||
|
vfsAutomat.setState({ isOpen: willOpen });
|
||||||
|
if (willOpen && vfsAutomat.state.files.length === 0 && !vfsAutomat.state.loadingFiles) {
|
||||||
|
void actions.loadFiles();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openVfs() {
|
||||||
|
actions.toggleVfs(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
closeVfs() {
|
||||||
|
actions.toggleVfs(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set width of the VFS editor panel.
|
||||||
|
* @param {number} width
|
||||||
|
*/
|
||||||
|
setVfsWidth(width) {
|
||||||
|
const clamped = Math.max(340, Math.min(Math.round(Number(width) || 540), 1200));
|
||||||
|
vfsAutomat.setState({ vfsWidth: clamped });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSearchQuery(searchQuery) {
|
||||||
|
vfsAutomat.setState({ searchQuery });
|
||||||
|
},
|
||||||
|
|
||||||
|
setNewFileDialogOpen(newFileDialogOpen) {
|
||||||
|
vfsAutomat.setState({ newFileDialogOpen });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load or refresh the file listing for the current company.
|
||||||
|
* @param {boolean} [silent=false]
|
||||||
|
*/
|
||||||
|
async loadFiles(silent = false) {
|
||||||
|
if (!silent) {
|
||||||
|
vfsAutomat.setState({ loadingFiles: true, loadError: null });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await vfsApi.list();
|
||||||
|
const files = Array.isArray(res?.files) ? res.files : [];
|
||||||
|
const currentSelected = vfsAutomat.state.selectedFileName;
|
||||||
|
const updatedSelectedFile = currentSelected
|
||||||
|
? files.find((f) => f.name === currentSelected) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
vfsAutomat.setState({
|
||||||
|
files,
|
||||||
|
selectedFile: updatedSelectedFile,
|
||||||
|
loadingFiles: false,
|
||||||
|
loadError: null,
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vfsAutomat] loadFiles failed:', err);
|
||||||
|
vfsAutomat.setState({
|
||||||
|
loadingFiles: false,
|
||||||
|
loadError: err?.message || 'Fehler beim Laden des VFS-Dateisystems',
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a file for viewing or editing.
|
||||||
|
* @param {string|null} fileName
|
||||||
|
*/
|
||||||
|
async selectFile(fileName) {
|
||||||
|
if (!fileName) {
|
||||||
|
vfsAutomat.setState({
|
||||||
|
selectedFileName: null,
|
||||||
|
selectedFile: null,
|
||||||
|
fileContent: '',
|
||||||
|
originalContent: '',
|
||||||
|
isDirty: false,
|
||||||
|
loadingContent: false,
|
||||||
|
saveError: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = vfsAutomat.state.files.find((f) => f.name === fileName) || {
|
||||||
|
name: fileName,
|
||||||
|
isText: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
vfsAutomat.setState({
|
||||||
|
selectedFileName: fileName,
|
||||||
|
selectedFile: file,
|
||||||
|
saveError: null,
|
||||||
|
isDirty: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (file.isText) {
|
||||||
|
vfsAutomat.setState({ loadingContent: true });
|
||||||
|
try {
|
||||||
|
const text = await vfsApi.getText(fileName);
|
||||||
|
// Only apply if still selected
|
||||||
|
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||||
|
vfsAutomat.setState({
|
||||||
|
fileContent: text,
|
||||||
|
originalContent: text,
|
||||||
|
isDirty: false,
|
||||||
|
loadingContent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[vfsAutomat] Failed to load text for "${fileName}":`, err);
|
||||||
|
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||||
|
vfsAutomat.setState({
|
||||||
|
loadingContent: false,
|
||||||
|
saveError: `Fehler beim Laden: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vfsAutomat.setState({
|
||||||
|
fileContent: '',
|
||||||
|
originalContent: '',
|
||||||
|
isDirty: false,
|
||||||
|
loadingContent: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the in-memory draft content of the currently open text file.
|
||||||
|
* @param {string} content
|
||||||
|
*/
|
||||||
|
updateContent(content) {
|
||||||
|
const isDirty = content !== vfsAutomat.state.originalContent;
|
||||||
|
vfsAutomat.setState({ fileContent: content, isDirty });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save changes to the currently selected file.
|
||||||
|
*/
|
||||||
|
async saveCurrentFile() {
|
||||||
|
const { selectedFileName, selectedFile, fileContent, isSaving } = vfsAutomat.state;
|
||||||
|
if (!selectedFileName || isSaving) return;
|
||||||
|
|
||||||
|
vfsAutomat.setState({ isSaving: true, saveError: null });
|
||||||
|
try {
|
||||||
|
await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime);
|
||||||
|
vfsAutomat.setState({
|
||||||
|
originalContent: fileContent,
|
||||||
|
isDirty: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
});
|
||||||
|
void actions.loadFiles(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vfsAutomat] saveCurrentFile failed:', err);
|
||||||
|
vfsAutomat.setState({
|
||||||
|
isSaving: false,
|
||||||
|
saveError: err?.message || 'Speichern fehlgeschlagen',
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new text file and immediately open it in the editor.
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} [initialContent='']
|
||||||
|
*/
|
||||||
|
async createFile(name, initialContent = '') {
|
||||||
|
const clean = String(name || '').trim().replace(/^[\/\\]+/, '');
|
||||||
|
if (!clean) throw new Error('Dateiname darf nicht leer sein');
|
||||||
|
|
||||||
|
vfsAutomat.setState({ isSaving: true, saveError: null, newFileDialogOpen: false });
|
||||||
|
try {
|
||||||
|
await vfsApi.save(clean, initialContent);
|
||||||
|
await actions.loadFiles(true);
|
||||||
|
await actions.selectFile(clean);
|
||||||
|
vfsAutomat.setState({ isSaving: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vfsAutomat] createFile failed:', err);
|
||||||
|
vfsAutomat.setState({
|
||||||
|
isSaving: false,
|
||||||
|
saveError: err?.message || 'Erstellen der Datei fehlgeschlagen',
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from VFS.
|
||||||
|
* @param {string} fileName
|
||||||
|
*/
|
||||||
|
async deleteFile(fileName) {
|
||||||
|
if (!fileName) return;
|
||||||
|
try {
|
||||||
|
await vfsApi.delete(fileName);
|
||||||
|
if (vfsAutomat.state.selectedFileName === fileName) {
|
||||||
|
actions.selectFile(null);
|
||||||
|
}
|
||||||
|
await actions.loadFiles(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vfsAutomat] deleteFile failed:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload one or more files to VFS.
|
||||||
|
* @param {File[]|FileList|File} files
|
||||||
|
* @param {string} [targetDir='']
|
||||||
|
*/
|
||||||
|
async uploadFiles(files, targetDir = '') {
|
||||||
|
vfsAutomat.setState({ isUploading: true, uploadError: null });
|
||||||
|
try {
|
||||||
|
const res = await vfsApi.upload(files, targetDir);
|
||||||
|
await actions.loadFiles(true);
|
||||||
|
vfsAutomat.setState({ isUploading: false, uploadError: null });
|
||||||
|
|
||||||
|
// If exactly one file was uploaded, select it
|
||||||
|
if (res?.uploaded?.length === 1) {
|
||||||
|
void actions.selectFile(res.uploaded[0].name);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vfsAutomat] uploadFiles failed:', err);
|
||||||
|
vfsAutomat.setState({
|
||||||
|
isUploading: false,
|
||||||
|
uploadError: err?.message || 'Upload fehlgeschlagen',
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a VFS file directly via browser.
|
||||||
|
* @param {string} fileName
|
||||||
|
*/
|
||||||
|
downloadFile(fileName) {
|
||||||
|
if (!fileName) return;
|
||||||
|
const clean = String(fileName).replace(/^\/+/, '');
|
||||||
|
const url = `/api/vfs/${clean}?download=1`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = clean.split('/').pop() || 'download';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (on logout or company switch).
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
vfsAutomat.setState({
|
||||||
|
files: [],
|
||||||
|
loadingFiles: false,
|
||||||
|
loadError: null,
|
||||||
|
selectedFileName: null,
|
||||||
|
selectedFile: null,
|
||||||
|
fileContent: '',
|
||||||
|
originalContent: '',
|
||||||
|
isDirty: false,
|
||||||
|
loadingContent: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
isUploading: false,
|
||||||
|
uploadError: null,
|
||||||
|
searchQuery: '',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vfsAutomat = new Automat(initialState, actions);
|
||||||
|
|
||||||
|
// Subscriptions for lifecycle coordination:
|
||||||
|
authAutomat.subscribe((authState) => {
|
||||||
|
if (authState.status === 'authed' && authState.user?.selectedCompanyId) {
|
||||||
|
if (vfsAutomat.state.isOpen) {
|
||||||
|
void vfsAutomat.actions.loadFiles(true);
|
||||||
|
}
|
||||||
|
} else if (authState.status === 'anon') {
|
||||||
|
vfsAutomat.actions.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE auto-refresh on 'vfs' events
|
||||||
|
sseAutomat.actions.on('vfs', () => {
|
||||||
|
void vfsAutomat.actions.loadFiles(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register on window for devtools inspector
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__AUTOMATS__ = window.__AUTOMATS__ || {};
|
||||||
|
window.__AUTOMATS__.vfsAutomat = vfsAutomat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default vfsAutomat;
|
||||||
@@ -26,10 +26,13 @@ import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
|
|||||||
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
|
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
|
||||||
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
|
import DirectionsRunRoundedIcon from '@mui/icons-material/DirectionsRunRounded';
|
||||||
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
|
import PsychologyRoundedIcon from '@mui/icons-material/PsychologyRounded';
|
||||||
|
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
|
||||||
|
|
||||||
import { setting as settingApi } from '../../api';
|
import { setting as settingApi } from '../../api';
|
||||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
||||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||||
|
import vfsAutomat from '../../automats/vfsAutomat';
|
||||||
|
import { VfsEditorPanel } from '../vfs';
|
||||||
import ChatFlowList from './ChatFlowList';
|
import ChatFlowList from './ChatFlowList';
|
||||||
import AskUserPrompt from './AskUserPrompt';
|
import AskUserPrompt from './AskUserPrompt';
|
||||||
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
import { ACCEPT_STRING, readFileAttachment } from './chatAttachments';
|
||||||
@@ -43,12 +46,16 @@ const DEFAULT_DRAWER_WIDTH = 460;
|
|||||||
const MIN_DRAWER_WIDTH = 320;
|
const MIN_DRAWER_WIDTH = 320;
|
||||||
|
|
||||||
export default class ChatDrawer extends PureComponent {
|
export default class ChatDrawer extends PureComponent {
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const s = chatAutomat.state;
|
const s = chatAutomat.state;
|
||||||
const settings = userSettingsAutomat.state;
|
const settings = userSettingsAutomat.state;
|
||||||
|
const vfs = vfsAutomat.state;
|
||||||
this.state = {
|
this.state = {
|
||||||
isOpen: s.isOpen,
|
isOpen: s.isOpen,
|
||||||
|
isVfsOpen: vfs.isOpen,
|
||||||
|
vfsWidth: vfs.vfsWidth || 480,
|
||||||
conversations: s.conversations,
|
conversations: s.conversations,
|
||||||
selectedConversationId: s.selectedConversationId,
|
selectedConversationId: s.selectedConversationId,
|
||||||
draftConversation: s.draftConversation,
|
draftConversation: s.draftConversation,
|
||||||
@@ -123,12 +130,18 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||||
|
isVfsOpen: state.isOpen,
|
||||||
|
vfsWidth: state.vfsWidth,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
if (this.focusTimeout) clearTimeout(this.focusTimeout);
|
if (this.focusTimeout) clearTimeout(this.focusTimeout);
|
||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
this.unsubSettings?.();
|
this.unsubSettings?.();
|
||||||
|
this.unsubVfs?.();
|
||||||
this.cleanupResizeListeners();
|
this.cleanupResizeListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +149,14 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
chatAutomat.actions.closeChat();
|
chatAutomat.actions.closeChat();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleResizeStart = (e) => {
|
handleInsertToChat = (text) => {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
inputText: prev.inputText ? `${prev.inputText}\n${text}` : text,
|
||||||
|
}));
|
||||||
|
this.focusInput(50);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleVfsResizerStart = (e) => {
|
||||||
if (e.button !== undefined && e.button !== 0) return;
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -144,25 +164,36 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
|
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
|
||||||
if (startX === undefined) return;
|
if (startX === undefined) return;
|
||||||
|
|
||||||
const startWidth = this.state.drawerWidth;
|
const startVfsWidth = this.state.vfsWidth;
|
||||||
this.setState({ isResizing: true });
|
const startChatWidth = this.state.drawerWidth;
|
||||||
|
const totalPairWidth = startVfsWidth + startChatWidth;
|
||||||
|
|
||||||
|
this.setState({ isResizing: true });
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = 'col-resize';
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
let currentWidth = startWidth;
|
let currentChatWidth = startChatWidth;
|
||||||
|
let currentVfsWidth = startVfsWidth;
|
||||||
|
|
||||||
const onMove = (moveEvt) => {
|
const onMove = (moveEvt) => {
|
||||||
moveEvt.preventDefault();
|
moveEvt.preventDefault();
|
||||||
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
|
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
|
||||||
if (clientX === undefined) return;
|
if (clientX === undefined) return;
|
||||||
|
|
||||||
const deltaX = startX - clientX;
|
const deltaX = clientX - startX;
|
||||||
const maxWidth = Math.min(window.innerWidth - 40, 1400);
|
const minVfs = 260;
|
||||||
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
|
const minChat = MIN_DRAWER_WIDTH; // 320
|
||||||
|
|
||||||
currentWidth = newWidth;
|
// Drag right (deltaX > 0): VFS increases, Chat decreases
|
||||||
this.setState({ drawerWidth: newWidth });
|
// Drag left (deltaX < 0): VFS decreases, Chat increases
|
||||||
|
const clampedVfs = Math.max(minVfs, Math.min(totalPairWidth - minChat, Math.round(startVfsWidth + deltaX)));
|
||||||
|
const clampedChat = totalPairWidth - clampedVfs;
|
||||||
|
|
||||||
|
currentVfsWidth = clampedVfs;
|
||||||
|
currentChatWidth = clampedChat;
|
||||||
|
|
||||||
|
vfsAutomat.actions.setVfsWidth(clampedVfs);
|
||||||
|
this.setState({ drawerWidth: clampedChat });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnd = () => {
|
const onEnd = () => {
|
||||||
@@ -177,7 +208,75 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
window.removeEventListener('touchend', onEnd);
|
window.removeEventListener('touchend', onEnd);
|
||||||
|
|
||||||
this.setState({ isResizing: false });
|
this.setState({ isResizing: false });
|
||||||
userSettingsAutomat.actions.setChatDrawerWidth(currentWidth);
|
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
|
||||||
|
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove, { passive: false });
|
||||||
|
window.addEventListener('pointerup', onEnd);
|
||||||
|
window.addEventListener('pointercancel', onEnd);
|
||||||
|
window.addEventListener('mousemove', onMove, { passive: false });
|
||||||
|
window.addEventListener('mouseup', onEnd);
|
||||||
|
window.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
window.addEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResizeStart = (e) => {
|
||||||
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const startX = e.clientX ?? (e.touches && e.touches[0]?.clientX);
|
||||||
|
if (startX === undefined) return;
|
||||||
|
|
||||||
|
const startWidth = this.state.drawerWidth;
|
||||||
|
const startVfsWidth = this.state.vfsWidth;
|
||||||
|
const isVfs = this.state.isVfsOpen;
|
||||||
|
|
||||||
|
this.setState({ isResizing: true });
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
let currentChatWidth = startWidth;
|
||||||
|
let currentVfsWidth = startVfsWidth;
|
||||||
|
|
||||||
|
const onMove = (moveEvt) => {
|
||||||
|
moveEvt.preventDefault();
|
||||||
|
const clientX = moveEvt.clientX ?? (moveEvt.touches && moveEvt.touches[0]?.clientX);
|
||||||
|
if (clientX === undefined) return;
|
||||||
|
|
||||||
|
const deltaX = startX - clientX;
|
||||||
|
|
||||||
|
if (isVfs) {
|
||||||
|
const maxVfs = Math.min(window.innerWidth - startWidth - 40, 1200);
|
||||||
|
const newVfsWidth = Math.max(260, Math.min(maxVfs, Math.round(startVfsWidth + deltaX)));
|
||||||
|
currentVfsWidth = newVfsWidth;
|
||||||
|
vfsAutomat.actions.setVfsWidth(newVfsWidth);
|
||||||
|
} else {
|
||||||
|
const maxWidth = Math.min(window.innerWidth - 40, 1400);
|
||||||
|
const newWidth = Math.max(MIN_DRAWER_WIDTH, Math.min(maxWidth, Math.round(startWidth + deltaX)));
|
||||||
|
currentChatWidth = newWidth;
|
||||||
|
this.setState({ drawerWidth: newWidth });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onEnd);
|
||||||
|
window.removeEventListener('pointercancel', onEnd);
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onEnd);
|
||||||
|
window.removeEventListener('touchmove', onMove);
|
||||||
|
window.removeEventListener('touchend', onEnd);
|
||||||
|
|
||||||
|
this.setState({ isResizing: false });
|
||||||
|
if (isVfs) {
|
||||||
|
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
|
||||||
|
} else {
|
||||||
|
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('pointermove', onMove, { passive: false });
|
window.addEventListener('pointermove', onMove, { passive: false });
|
||||||
@@ -199,6 +298,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
handleSelectConversation = (e) => {
|
handleSelectConversation = (e) => {
|
||||||
const id = Number(e.target.value);
|
const id = Number(e.target.value);
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -427,6 +527,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
modelslugflag,
|
modelslugflag,
|
||||||
modelreasoning,
|
modelreasoning,
|
||||||
modelslug,
|
modelslug,
|
||||||
|
isVfsOpen,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -695,11 +796,36 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ ml: 1, flexShrink: 0 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 1, flexShrink: 0 }}>
|
||||||
<CloseRoundedIcon fontSize="small" />
|
<Tooltip title={isVfsOpen ? 'VFS-Dateisystem schließen' : 'VFS-Dateisystem parallel öffnen'}>
|
||||||
</IconButton>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => vfsAutomat.actions.toggleVfs()}
|
||||||
|
aria-label="VFS Dateisystem"
|
||||||
|
sx={{
|
||||||
|
p: 0.5,
|
||||||
|
color: isVfsOpen ? 'primary.main' : 'text.secondary',
|
||||||
|
bgcolor: isVfsOpen ? 'action.selected' : 'transparent',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isVfsOpen ? 'primary.main' : 'transparent',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'primary.main',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpenRoundedIcon sx={{ fontSize: 19 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<IconButton size="small" onClick={this.handleClose} aria-label="Chat schließen" sx={{ p: 0.5 }}>
|
||||||
|
<CloseRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
||||||
{/* Conversation Selector Bar */}
|
{/* Conversation Selector Bar */}
|
||||||
{conversations.length > 0 && (
|
{conversations.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
@@ -1095,7 +1221,8 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isOpen, drawerWidth, isResizing } = this.state;
|
const { isOpen, drawerWidth, isResizing, isVfsOpen, vfsWidth } = this.state;
|
||||||
|
const totalWidth = isVfsOpen ? drawerWidth + vfsWidth : drawerWidth;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1106,10 +1233,10 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
zIndex: theme.zIndex.drawer + 2,
|
zIndex: theme.zIndex.drawer + 2,
|
||||||
'& .MuiDrawer-paper': {
|
'& .MuiDrawer-paper': {
|
||||||
width: { xs: '100vw', sm: `${drawerWidth}px` },
|
width: { xs: '100vw', sm: `${totalWidth}px` },
|
||||||
maxWidth: '100vw',
|
maxWidth: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'row',
|
||||||
boxShadow: SHADOW_DRAWER,
|
boxShadow: SHADOW_DRAWER,
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
@@ -1122,10 +1249,10 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
},
|
},
|
||||||
paper: {
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
width: { xs: '100vw', sm: `${drawerWidth}px` },
|
width: { xs: '100vw', sm: `${totalWidth}px` },
|
||||||
maxWidth: '100vw',
|
maxWidth: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'row',
|
||||||
boxShadow: SHADOW_DRAWER,
|
boxShadow: SHADOW_DRAWER,
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
@@ -1177,11 +1304,79 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{this.renderHeader()}
|
{/* Left: VFS Filesystem Editor Panel (parallel to chat) */}
|
||||||
{this.renderBody()}
|
{isVfsOpen && (
|
||||||
{this.renderFooter()}
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: { xs: '100%', sm: `${vfsWidth}px` },
|
||||||
|
minWidth: { sm: 340 },
|
||||||
|
maxWidth: { sm: 'calc(100vw - 320px)' },
|
||||||
|
height: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VfsEditorPanel onInsertToChat={this.handleInsertToChat} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vertical Split Resizer between VFS and Chat */}
|
||||||
|
{isVfsOpen && (
|
||||||
|
<Box
|
||||||
|
onPointerDown={this.handleVfsResizerStart}
|
||||||
|
onMouseDown={this.handleVfsResizerStart}
|
||||||
|
title="Ziehen zum Anpassen der Spaltenbreiten"
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
bgcolor: 'divider',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: { xs: 'none', sm: 'flex' },
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
touchAction: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
transition: 'background-color 0.15s, box-shadow 0.15s',
|
||||||
|
'&:hover, &:active': {
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
boxShadow: `0 0 8px ${BLUE_GLOW}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 2,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: Chat Panel */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: isVfsOpen ? { xs: '100%', sm: `${drawerWidth}px` } : '100%',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: { sm: 320 },
|
||||||
|
display: isVfsOpen ? { xs: 'none', sm: 'flex' } : 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.renderHeader()}
|
||||||
|
{this.renderBody()}
|
||||||
|
{this.renderFooter()}
|
||||||
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { thumb } from '../../api';
|
|||||||
import {
|
import {
|
||||||
GREEN,
|
GREEN,
|
||||||
GREEN_BRIGHT,
|
GREEN_BRIGHT,
|
||||||
ICE,
|
|
||||||
INK_STRONG,
|
INK_STRONG,
|
||||||
SCRIM_LABEL,
|
SCRIM_LABEL,
|
||||||
SCRIM_TILE,
|
SCRIM_TILE,
|
||||||
@@ -409,69 +408,92 @@ export class ArticleTile extends Tile {
|
|||||||
textShadow: TEXT_GLOW_DARK,
|
textShadow: TEXT_GLOW_DARK,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{article?.brand?.name ? (
|
{/* Row 1: Brand */}
|
||||||
<BrandLabel
|
<Box
|
||||||
brand={article.brand}
|
sx={{
|
||||||
name={article.brand.name}
|
height: 18,
|
||||||
size={14}
|
minHeight: 18,
|
||||||
spacing={0.5}
|
display: 'flex',
|
||||||
typographyProps={{
|
alignItems: 'center',
|
||||||
variant: 'caption',
|
mb: 0.25,
|
||||||
noWrap: true,
|
}}
|
||||||
sx: {
|
>
|
||||||
color: ICE,
|
{article?.brand?.name ? (
|
||||||
opacity: 1,
|
<BrandLabel
|
||||||
fontWeight: 500,
|
brand={article.brand}
|
||||||
lineHeight: 1.1,
|
name={article.brand.name}
|
||||||
textShadow: TEXT_GLOW_DARK,
|
size={14}
|
||||||
},
|
spacing={0.5}
|
||||||
}}
|
typographyProps={{
|
||||||
sx={{ mb: 0.25 }}
|
variant: 'caption',
|
||||||
/>
|
noWrap: true,
|
||||||
) : null}
|
sx: {
|
||||||
{label ? (
|
color: INK_STRONG,
|
||||||
<Typography
|
fontWeight: 600,
|
||||||
variant="body2"
|
lineHeight: 1.2,
|
||||||
fontWeight={600}
|
py: '2px',
|
||||||
noWrap
|
textShadow: TEXT_GLOW_LIGHT,
|
||||||
sx={{
|
},
|
||||||
color: INK_STRONG,
|
}}
|
||||||
lineHeight: 1.2,
|
/>
|
||||||
textShadow: TEXT_GLOW_LIGHT,
|
) : null}
|
||||||
}}
|
</Box>
|
||||||
>
|
|
||||||
{label}
|
{/* Rows 2 & 3: Name */}
|
||||||
</Typography>
|
<Typography
|
||||||
) : null}
|
variant="body2"
|
||||||
{sku ? (
|
fontWeight={600}
|
||||||
<Typography
|
sx={{
|
||||||
variant="caption"
|
color: INK_STRONG,
|
||||||
noWrap
|
lineHeight: 1.2,
|
||||||
sx={{
|
height: '2.4em',
|
||||||
color: WHITE,
|
textShadow: TEXT_GLOW_LIGHT,
|
||||||
textShadow: TEXT_GLOW_DARK,
|
display: '-webkit-box',
|
||||||
}}
|
WebkitLineClamp: 2,
|
||||||
>
|
WebkitBoxOrient: 'vertical',
|
||||||
{sku}
|
overflow: 'hidden',
|
||||||
</Typography>
|
wordBreak: 'break-word',
|
||||||
) : null}
|
}}
|
||||||
{grossPrice != null && (
|
>
|
||||||
<Typography
|
{label || article?.name?.trim() || '\u00A0'}
|
||||||
variant="caption"
|
</Typography>
|
||||||
fontWeight={700}
|
|
||||||
component="div"
|
{/* Row 4: SKU */}
|
||||||
sx={{
|
<Typography
|
||||||
color: GREEN_BRIGHT,
|
variant="caption"
|
||||||
fontWeight: 700,
|
noWrap
|
||||||
fontSize: '0.8rem',
|
component="div"
|
||||||
lineHeight: 1.2,
|
sx={{
|
||||||
mt: 0.25,
|
color: WHITE,
|
||||||
textShadow: TEXT_GLOW_DARK,
|
textShadow: TEXT_GLOW_DARK,
|
||||||
}}
|
lineHeight: 1.2,
|
||||||
>
|
height: '1.2em',
|
||||||
{formatPrice(grossPrice)}
|
overflow: 'hidden',
|
||||||
</Typography>
|
textOverflow: 'ellipsis',
|
||||||
)}
|
}}
|
||||||
|
>
|
||||||
|
{sku || '\u00A0'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Row 5: Price */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
color: GREEN_BRIGHT,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
height: '1.2em',
|
||||||
|
mt: 0.25,
|
||||||
|
textShadow: TEXT_GLOW_DARK,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{grossPrice != null ? formatPrice(grossPrice) : '\u00A0'}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
);
|
);
|
||||||
|
|||||||
194
src/components/vfs/VfsEditorPanel.jsx
Normal file
194
src/components/vfs/VfsEditorPanel.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
|
||||||
|
import vfsAutomat from '../../automats/vfsAutomat';
|
||||||
|
import VfsFileExplorer from './VfsFileExplorer';
|
||||||
|
import VfsTextEditor from './VfsTextEditor';
|
||||||
|
import VfsFilePreview from './VfsFilePreview';
|
||||||
|
import VfsNewFileDialog from './VfsNewFileDialog';
|
||||||
|
|
||||||
|
export default class VfsEditorPanel extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const s = vfsAutomat.state;
|
||||||
|
this.state = {
|
||||||
|
files: s.files,
|
||||||
|
loadingFiles: s.loadingFiles,
|
||||||
|
loadError: s.loadError,
|
||||||
|
selectedFileName: s.selectedFileName,
|
||||||
|
selectedFile: s.selectedFile,
|
||||||
|
fileContent: s.fileContent,
|
||||||
|
originalContent: s.originalContent,
|
||||||
|
isDirty: s.isDirty,
|
||||||
|
loadingContent: s.loadingContent,
|
||||||
|
isSaving: s.isSaving,
|
||||||
|
saveError: s.saveError,
|
||||||
|
isUploading: s.isUploading,
|
||||||
|
searchQuery: s.searchQuery,
|
||||||
|
newFileDialogOpen: s.newFileDialogOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = vfsAutomat.subscribe(this, (state) => ({
|
||||||
|
files: state.files,
|
||||||
|
loadingFiles: state.loadingFiles,
|
||||||
|
loadError: state.loadError,
|
||||||
|
selectedFileName: state.selectedFileName,
|
||||||
|
selectedFile: state.selectedFile,
|
||||||
|
fileContent: state.fileContent,
|
||||||
|
originalContent: state.originalContent,
|
||||||
|
isDirty: state.isDirty,
|
||||||
|
loadingContent: state.loadingContent,
|
||||||
|
isSaving: state.isSaving,
|
||||||
|
saveError: state.saveError,
|
||||||
|
isUploading: state.isUploading,
|
||||||
|
searchQuery: state.searchQuery,
|
||||||
|
newFileDialogOpen: state.newFileDialogOpen,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.state.files.length === 0 && !this.state.loadingFiles) {
|
||||||
|
void vfsAutomat.actions.loadFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMentionInChat = (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (this.props.onInsertToChat) {
|
||||||
|
const isImg = file.isImage;
|
||||||
|
const tag = isImg
|
||||||
|
? ``
|
||||||
|
: `[${file.name}](${file.name})`;
|
||||||
|
this.props.onInsertToChat(tag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
loadingFiles,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFile,
|
||||||
|
fileContent,
|
||||||
|
originalContent,
|
||||||
|
isDirty,
|
||||||
|
loadingContent,
|
||||||
|
isSaving,
|
||||||
|
saveError,
|
||||||
|
searchQuery,
|
||||||
|
newFileDialogOpen,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRight: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedFileName && selectedFile?.isText ? (
|
||||||
|
/* Single Column Mode: Text Editor */
|
||||||
|
<VfsTextEditor
|
||||||
|
fileName={selectedFileName}
|
||||||
|
file={selectedFile}
|
||||||
|
content={fileContent}
|
||||||
|
originalContent={originalContent}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isSaving={isSaving}
|
||||||
|
saveError={saveError}
|
||||||
|
loadingContent={loadingContent}
|
||||||
|
onChange={(c) => vfsAutomat.actions.updateContent(c)}
|
||||||
|
onSave={() => vfsAutomat.actions.saveCurrentFile()}
|
||||||
|
onClose={() => vfsAutomat.actions.selectFile(null)}
|
||||||
|
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
|
||||||
|
onMentionInChat={() => this.handleMentionInChat(selectedFile)}
|
||||||
|
/>
|
||||||
|
) : selectedFileName && !selectedFile?.isText ? (
|
||||||
|
/* Single Column Mode: Preview (Image, PDF, Binary) */
|
||||||
|
<VfsFilePreview
|
||||||
|
fileName={selectedFileName}
|
||||||
|
file={selectedFile}
|
||||||
|
onClose={() => vfsAutomat.actions.selectFile(null)}
|
||||||
|
onDownload={() => vfsAutomat.actions.downloadFile(selectedFileName)}
|
||||||
|
onMentionInChat={() => this.handleMentionInChat(selectedFile)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* Single Column Mode: File Explorer */
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
px: 2,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
minHeight: 52,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<FolderOpenRoundedIcon color="primary" sx={{ fontSize: 22 }} />
|
||||||
|
<Typography variant="subtitle1" fontWeight={700}>
|
||||||
|
VFS Dateisystem
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Tooltip title="VFS-Dateisystem schließen">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => vfsAutomat.actions.closeVfs()}
|
||||||
|
aria-label="VFS schließen"
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
|
<VfsFileExplorer
|
||||||
|
files={files}
|
||||||
|
loading={loadingFiles}
|
||||||
|
selectedFileName={selectedFileName}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
|
||||||
|
onRefresh={() => vfsAutomat.actions.loadFiles()}
|
||||||
|
onOpenNewDialog={() => vfsAutomat.actions.setNewFileDialogOpen(true)}
|
||||||
|
onSearchChange={(q) => vfsAutomat.actions.setSearchQuery(q)}
|
||||||
|
onDownload={(name) => vfsAutomat.actions.downloadFile(name)}
|
||||||
|
onDelete={(name) => vfsAutomat.actions.deleteFile(name)}
|
||||||
|
onUpload={(filesToUpload) => vfsAutomat.actions.uploadFiles(filesToUpload)}
|
||||||
|
onMentionInChat={this.handleMentionInChat}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New File Dialog */}
|
||||||
|
<VfsNewFileDialog
|
||||||
|
open={newFileDialogOpen}
|
||||||
|
onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)}
|
||||||
|
onCreate={(name) => vfsAutomat.actions.createFile(name)}
|
||||||
|
isCreating={isSaving}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
735
src/components/vfs/VfsFileExplorer.jsx
Normal file
735
src/components/vfs/VfsFileExplorer.jsx
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import InputBase from '@mui/material/InputBase';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||||
|
import ClearRoundedIcon from '@mui/icons-material/ClearRounded';
|
||||||
|
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
||||||
|
import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded';
|
||||||
|
import CloudUploadRoundedIcon from '@mui/icons-material/CloudUploadRounded';
|
||||||
|
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||||
|
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
||||||
|
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||||
|
import VisibilityRoundedIcon from '@mui/icons-material/VisibilityRounded';
|
||||||
|
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded';
|
||||||
|
import FolderRoundedIcon from '@mui/icons-material/FolderRounded';
|
||||||
|
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
|
||||||
|
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||||
|
import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded';
|
||||||
|
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||||
|
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
|
|
||||||
|
import VfsFileIcon, { getCategoryBadge } from './VfsFileIcon';
|
||||||
|
import { formatVfsSize, formatVfsDate } from './vfsUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all ancestor directory paths for a file or folder path.
|
||||||
|
* e.g. "docs/api/v1/spec.json" -> ["docs", "docs/api", "docs/api/v1"]
|
||||||
|
*/
|
||||||
|
function getParentFolderPaths(filePath) {
|
||||||
|
if (!filePath) return [];
|
||||||
|
const clean = String(filePath).replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
const parts = clean.split('/').filter(Boolean);
|
||||||
|
const paths = [];
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
paths.push(parts.slice(0, i + 1).join('/'));
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a hierarchical tree node structure from a flat list of VfsFiles.
|
||||||
|
* In every node, folders and files are separated and can be rendered recursively.
|
||||||
|
*/
|
||||||
|
function buildFileTree(files, searchQuery = '') {
|
||||||
|
const q = searchQuery ? searchQuery.trim().toLowerCase() : '';
|
||||||
|
const filtered = (files || []).filter((f) => {
|
||||||
|
if (!q) return true;
|
||||||
|
return f.name.toLowerCase().includes(q) || (f.mime && f.mime.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = {
|
||||||
|
name: '',
|
||||||
|
path: '',
|
||||||
|
isDirectory: true,
|
||||||
|
folders: new Map(),
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const file of filtered) {
|
||||||
|
const cleanPath = String(file.name || '').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
if (!cleanPath) continue;
|
||||||
|
|
||||||
|
const parts = cleanPath.split('/').filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const folderName = parts[i];
|
||||||
|
const folderPath = parts.slice(0, i + 1).join('/');
|
||||||
|
|
||||||
|
if (!current.folders.has(folderName)) {
|
||||||
|
current.folders.set(folderName, {
|
||||||
|
name: folderName,
|
||||||
|
path: folderPath,
|
||||||
|
isDirectory: true,
|
||||||
|
folders: new Map(),
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current = current.folders.get(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = parts[parts.length - 1];
|
||||||
|
current.files.push({
|
||||||
|
name: fileName,
|
||||||
|
path: cleanPath,
|
||||||
|
isDirectory: false,
|
||||||
|
fileData: file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate recursive file counts
|
||||||
|
function countFiles(node) {
|
||||||
|
let count = node.files.length;
|
||||||
|
for (const folder of node.folders.values()) {
|
||||||
|
count += countFiles(folder);
|
||||||
|
}
|
||||||
|
node.totalFileCount = count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
countFiles(root);
|
||||||
|
|
||||||
|
return { root, totalCount: filtered.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class VfsFileExplorer extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const initialExpanded = new Set();
|
||||||
|
if (props.selectedFileName) {
|
||||||
|
for (const p of getParentFolderPaths(props.selectedFileName)) {
|
||||||
|
initialExpanded.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
menuAnchor: null,
|
||||||
|
menuFile: null,
|
||||||
|
deleteConfirmFile: null,
|
||||||
|
isDraggingOver: false,
|
||||||
|
expandedFolders: initialExpanded,
|
||||||
|
};
|
||||||
|
this.fileInputRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) {
|
||||||
|
const parents = getParentFolderPaths(this.props.selectedFileName);
|
||||||
|
if (parents.length > 0) {
|
||||||
|
this.setState((prev) => {
|
||||||
|
const next = new Set(prev.expandedFolders);
|
||||||
|
let changed = false;
|
||||||
|
for (const p of parents) {
|
||||||
|
if (!next.has(p)) {
|
||||||
|
next.add(p);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? { expandedFolders: next } : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFolder = (folderPath) => {
|
||||||
|
this.setState((prev) => {
|
||||||
|
const next = new Set(prev.expandedFolders);
|
||||||
|
if (next.has(folderPath)) {
|
||||||
|
next.delete(folderPath);
|
||||||
|
} else {
|
||||||
|
next.add(folderPath);
|
||||||
|
}
|
||||||
|
return { expandedFolders: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
handleOpenMenu = (e, file) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
this.setState({ menuAnchor: e.currentTarget, menuFile: file });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseMenu = () => {
|
||||||
|
this.setState({ menuAnchor: null, menuFile: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTriggerUpload = () => {
|
||||||
|
this.fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFileInputChange = (e) => {
|
||||||
|
const files = e.target?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
this.props.onUpload(Array.from(files));
|
||||||
|
}
|
||||||
|
if (e.target) e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragEnter = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
this.setState({ isDraggingOver: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragOver = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!this.state.isDraggingOver && e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
this.setState({ isDraggingOver: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragLeave = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||||
|
this.setState({ isDraggingOver: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setState({ isDraggingOver: false });
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
this.props.onUpload(Array.from(files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCopyPath = (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
navigator.clipboard?.writeText?.(file.name);
|
||||||
|
this.handleCloseMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMentionInChat = (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (this.props.onMentionInChat) {
|
||||||
|
this.props.onMentionInChat(file);
|
||||||
|
}
|
||||||
|
this.handleCloseMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFolderNode = (folder, depth) => {
|
||||||
|
const { searchQuery } = this.props;
|
||||||
|
const isSearching = Boolean(searchQuery && searchQuery.trim());
|
||||||
|
const isExpanded = isSearching || this.state.expandedFolders.has(folder.path);
|
||||||
|
|
||||||
|
const subfolders = Array.from(folder.folders.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
const files = [...folder.files].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={folder.path} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Folder Header Row */}
|
||||||
|
<Box
|
||||||
|
onClick={() => this.toggleFolder(folder.path)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1,
|
||||||
|
pl: `${depth * 14 + 6}px`,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
transition: 'background-color 0.1s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Chevron */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
mr: 0.4,
|
||||||
|
color: 'text.secondary',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<KeyboardArrowDownRoundedIcon sx={{ fontSize: 17 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowRightRoundedIcon sx={{ fontSize: 17 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Folder Icon */}
|
||||||
|
{isExpanded ? (
|
||||||
|
<FolderOpenRoundedIcon
|
||||||
|
sx={{ fontSize: 18, color: 'primary.main', mr: 0.8, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FolderRoundedIcon
|
||||||
|
sx={{ fontSize: 18, opacity: 0.75, color: 'text.secondary', mr: 0.8, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Folder Name */}
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ fontSize: '0.82rem', fontFamily: 'monospace', color: 'text.primary', flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Total Files Count in Folder */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'text.disabled',
|
||||||
|
ml: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{folder.totalFileCount}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Folder Children (Directories FIRST, then files) */}
|
||||||
|
{isExpanded && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 1. Subdirectories FIRST */}
|
||||||
|
{subfolders.map((sub) => this.renderFolderNode(sub, depth + 1))}
|
||||||
|
|
||||||
|
{/* 2. Files NEXT */}
|
||||||
|
{files.map((file) => this.renderFileNode(file, depth + 1))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFileNode = (file, depth) => {
|
||||||
|
const { selectedFileName, onSelectFile } = this.props;
|
||||||
|
const isSelected = file.path === selectedFileName;
|
||||||
|
const badge = getCategoryBadge(file.path, file.fileData.mime);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => onSelectFile(file.path)}
|
||||||
|
sx={(theme) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 0.5,
|
||||||
|
px: 1,
|
||||||
|
pl: `${depth * 14 + 26}px`,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
bgcolor: isSelected
|
||||||
|
? theme.palette.mode === 'dark'
|
||||||
|
? 'primary.dark'
|
||||||
|
: 'action.selected'
|
||||||
|
: 'transparent',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isSelected ? 'primary.main' : 'transparent',
|
||||||
|
transition: 'background-color 0.1s, border-color 0.1s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: isSelected
|
||||||
|
? theme.palette.mode === 'dark'
|
||||||
|
? 'primary.dark'
|
||||||
|
: 'action.selected'
|
||||||
|
: 'action.hover',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VfsFileIcon
|
||||||
|
fileName={file.path}
|
||||||
|
mime={file.fileData.mime}
|
||||||
|
sx={{ mr: 1, flexShrink: 0, fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ minWidth: 0, flexGrow: 1, pr: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={isSelected ? 600 : 500}
|
||||||
|
noWrap
|
||||||
|
sx={{ fontFamily: 'monospace', fontSize: '0.82rem' }}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={badge.label}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 17,
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||||
|
color: badge.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mt: 0.15 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
||||||
|
{formatVfsSize(file.fileData.size)}
|
||||||
|
</Typography>
|
||||||
|
{file.fileData.updated_at && (
|
||||||
|
<Typography variant="caption" color="text.disabled" sx={{ fontSize: '0.68rem' }}>
|
||||||
|
{formatVfsDate(file.fileData.updated_at)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Quick action buttons on hover / select */}
|
||||||
|
<Box
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.2,
|
||||||
|
opacity: isSelected ? 1 : 0.7,
|
||||||
|
'&:hover': { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="Herunterladen">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.props.onDownload(file.path)}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
<DownloadRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => this.handleOpenMenu(e, file.fileData)}
|
||||||
|
sx={{ p: 0.5 }}
|
||||||
|
>
|
||||||
|
<MoreVertRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderTree() {
|
||||||
|
const { files, searchQuery } = this.props;
|
||||||
|
const { root, totalCount } = buildFileTree(files, searchQuery);
|
||||||
|
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: 6,
|
||||||
|
px: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'text.secondary',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderRoundedIcon sx={{ fontSize: 44, opacity: 0.3 }} />
|
||||||
|
<Typography variant="body2">
|
||||||
|
{searchQuery ? `Keine Dateien für "${searchQuery}" gefunden` : 'Keine Dateien im VFS vorhanden'}
|
||||||
|
</Typography>
|
||||||
|
{!searchQuery && (
|
||||||
|
<Typography variant="caption" sx={{ maxWidth: 220 }}>
|
||||||
|
Klicke oben auf Upload oder erstelle eine neue Datei.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFolders = Array.from(root.folders.values()).sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
const rootFiles = [...root.files].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
|
{/* Top-level directories FIRST */}
|
||||||
|
{rootFolders.map((folder) => this.renderFolderNode(folder, 0))}
|
||||||
|
|
||||||
|
{/* Top-level files NEXT */}
|
||||||
|
{rootFiles.map((file) => this.renderFileNode(file, 0))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
onRefresh,
|
||||||
|
onOpenNewDialog,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
files,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const { menuAnchor, menuFile, isDraggingOver } = this.state;
|
||||||
|
const isText = menuFile?.isText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onDragEnter={this.handleDragEnter}
|
||||||
|
onDragOver={this.handleDragOver}
|
||||||
|
onDragLeave={this.handleDragLeave}
|
||||||
|
onDrop={this.handleDrop}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
bgcolor: isDraggingOver ? 'action.selected' : 'inherit',
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hidden Upload Input */}
|
||||||
|
<input
|
||||||
|
ref={this.fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={this.handleFileInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drag & Drop Overlay */}
|
||||||
|
{isDraggingOver && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 8,
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
opacity: 0.94,
|
||||||
|
zIndex: 10,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudUploadRoundedIcon color="primary" sx={{ fontSize: 44 }} />
|
||||||
|
<Typography variant="subtitle1" fontWeight={700} color="primary.main">
|
||||||
|
Dateien im VFS ablegen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Die Dateien werden sofort auf den Server hochgeladen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Explorer Header / Actions */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.25,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top Bar with Action Buttons */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
{loading && <CircularProgress size={16} thickness={4} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ml: 'auto' }}>
|
||||||
|
<Tooltip title="Aktualisieren">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={onRefresh} disabled={loading} sx={{ p: 0.6 }}>
|
||||||
|
<RefreshRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Datei(en) hochladen">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<CloudUploadRoundedIcon />}
|
||||||
|
onClick={this.handleTriggerUpload}
|
||||||
|
sx={{ borderRadius: 2, py: 0.3, px: 1, fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Neue Datei anlegen">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<NoteAddRoundedIcon />}
|
||||||
|
onClick={onOpenNewDialog}
|
||||||
|
sx={{ borderRadius: 2, py: 0.3, px: 1, fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Search Filter Bar */}
|
||||||
|
<Box
|
||||||
|
sx={(theme) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: 1,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
'&:focus-within': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.09)' : '#fff',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SearchRoundedIcon sx={{ fontSize: 18, color: 'text.secondary', mr: 0.75 }} />
|
||||||
|
<InputBase
|
||||||
|
fullWidth
|
||||||
|
placeholder="VFS durchsuchen…"
|
||||||
|
value={searchQuery || ''}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
sx={{ fontSize: '0.82rem' }}
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<IconButton size="small" onClick={() => onSearchChange('')} sx={{ p: 0.2 }}>
|
||||||
|
<ClearRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Scrollable File List */}
|
||||||
|
<Box sx={{ flexGrow: 1, overflowY: 'auto', p: 1.5 }}>
|
||||||
|
{this.renderTree()}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Context Menu */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={this.handleCloseMenu}
|
||||||
|
PaperProps={{ sx: { borderRadius: 2, minWidth: 180 } }}
|
||||||
|
>
|
||||||
|
{isText && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (menuFile) this.props.onSelectFile(menuFile.name);
|
||||||
|
this.handleCloseMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<EditRoundedIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Im Editor bearbeiten" />
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isText && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (menuFile) this.props.onSelectFile(menuFile.name);
|
||||||
|
this.handleCloseMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<VisibilityRoundedIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Vorschau" />
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (menuFile) this.props.onDownload(menuFile.name);
|
||||||
|
this.handleCloseMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DownloadRoundedIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Herunterladen" />
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={() => this.handleCopyPath(menuFile)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ContentCopyRoundedIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Pfad kopieren" />
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{this.props.onMentionInChat && (
|
||||||
|
<MenuItem onClick={() => this.handleMentionInChat(menuFile)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="In Chat erwähnen" />
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) {
|
||||||
|
this.props.onDelete(menuFile.name);
|
||||||
|
}
|
||||||
|
this.handleCloseMenu();
|
||||||
|
}}
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ color: 'error.main' }}>
|
||||||
|
<DeleteOutlineRoundedIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Datei löschen" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/components/vfs/VfsFileIcon.jsx
Normal file
80
src/components/vfs/VfsFileIcon.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||||
|
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
||||||
|
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
||||||
|
import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
|
||||||
|
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
|
||||||
|
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||||
|
import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded';
|
||||||
|
import ArticleRoundedIcon from '@mui/icons-material/ArticleRounded';
|
||||||
|
import { getVfsCategory } from './vfsUtils';
|
||||||
|
|
||||||
|
const CATEGORY_CONFIG = {
|
||||||
|
skill: {
|
||||||
|
icon: AutoAwesomeRoundedIcon,
|
||||||
|
color: '#9c27b0', // Purple
|
||||||
|
label: 'Skill',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
icon: CodeRoundedIcon,
|
||||||
|
color: '#e65100', // Deep Orange
|
||||||
|
label: 'Code',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
icon: TableChartRoundedIcon,
|
||||||
|
color: '#2e7d32', // Green
|
||||||
|
label: 'Tabelle',
|
||||||
|
},
|
||||||
|
pdf: {
|
||||||
|
icon: PictureAsPdfRoundedIcon,
|
||||||
|
color: '#d32f2f', // Red
|
||||||
|
label: 'PDF',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
icon: ImageRoundedIcon,
|
||||||
|
color: '#0288d1', // Light Blue / Cyan
|
||||||
|
label: 'Bild',
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
icon: ArticleRoundedIcon,
|
||||||
|
color: '#1976d2', // Blue
|
||||||
|
label: 'Markdown',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
icon: DescriptionRoundedIcon,
|
||||||
|
color: '#546e7a', // Blue Grey
|
||||||
|
label: 'Text',
|
||||||
|
},
|
||||||
|
binary: {
|
||||||
|
icon: InsertDriveFileRoundedIcon,
|
||||||
|
color: '#757575', // Grey
|
||||||
|
label: 'Datei',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VfsFileIcon({ fileName, mime, sx = {}, fontSize = 'small' }) {
|
||||||
|
const category = getVfsCategory(fileName, mime);
|
||||||
|
const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary;
|
||||||
|
const IconComponent = cfg.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: cfg.color,
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconComponent fontSize={fontSize} sx={{ color: 'inherit' }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCategoryBadge(fileName, mime) {
|
||||||
|
const category = getVfsCategory(fileName, mime);
|
||||||
|
return CATEGORY_CONFIG[category] || CATEGORY_CONFIG.binary;
|
||||||
|
}
|
||||||
232
src/components/vfs/VfsFilePreview.jsx
Normal file
232
src/components/vfs/VfsFilePreview.jsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
|
||||||
|
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
|
|
||||||
|
import VfsFileIcon, { getCategoryBadge } from './VfsFileIcon';
|
||||||
|
import { formatVfsSize, formatVfsDate } from './vfsUtils';
|
||||||
|
|
||||||
|
export default class VfsFilePreview extends PureComponent {
|
||||||
|
renderImagePreview(url) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 3,
|
||||||
|
overflow: 'auto',
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(45deg, rgba(0,0,0,0.06) 25%, transparent 25%), linear-gradient(-45deg, rgba(0,0,0,0.06) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.06) 75%), linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.06) 75%)',
|
||||||
|
backgroundSize: '20px 20px',
|
||||||
|
backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
sx={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPdfPreview(url, fileName) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 4,
|
||||||
|
gap: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VfsFileIcon fileName={fileName} fontSize="large" sx={{ transform: 'scale(1.8)', mb: 1 }} />
|
||||||
|
<Typography variant="h6" fontWeight={700}>
|
||||||
|
PDF-Dokument
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
|
||||||
|
Klicke unten, um das Dokument in einem neuen Tab zu öffnen oder herunterzuladen.
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, mt: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<OpenInNewRoundedIcon />}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Im Browser öffnen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadRoundedIcon />}
|
||||||
|
onClick={this.props.onDownload}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Herunterladen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBinaryPreview(fileName, file) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 4,
|
||||||
|
gap: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VfsFileIcon fileName={fileName} fontSize="large" sx={{ transform: 'scale(1.8)', mb: 1 }} />
|
||||||
|
<Typography variant="h6" fontWeight={700}>
|
||||||
|
Binärdatei
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
|
||||||
|
Dieser Dateityp kann nicht direkt im Browser bearbeitet werden.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<DownloadRoundedIcon />}
|
||||||
|
onClick={this.props.onDownload}
|
||||||
|
sx={{ borderRadius: 2, mt: 1 }}
|
||||||
|
>
|
||||||
|
Datei herunterladen ({formatVfsSize(file?.size)})
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { fileName, file, onClose, onDownload, onMentionInChat } = this.props;
|
||||||
|
const badge = getCategoryBadge(fileName, file?.mime);
|
||||||
|
const vfsUrl = `/api/vfs/${encodeURIComponent(fileName).replace(/%2F/g, '/')}?raw=1`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
{/* Preview Header */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.25,
|
||||||
|
px: 2,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
flexShrink: 0,
|
||||||
|
minHeight: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
|
||||||
|
<Tooltip title="Zurück zur Dateiliste">
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ p: 0.5, mr: 0.25 }}>
|
||||||
|
<ArrowBackRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={700}
|
||||||
|
noWrap
|
||||||
|
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={badge.label}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||||
|
color: badge.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
|
{onMentionInChat && (
|
||||||
|
<Tooltip title="In Chat erwähnen">
|
||||||
|
<IconButton size="small" onClick={onMentionInChat} sx={{ p: 0.6 }}>
|
||||||
|
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip title="Herunterladen">
|
||||||
|
<IconButton size="small" onClick={onDownload} sx={{ p: 0.6 }}>
|
||||||
|
<DownloadRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Vorschau schließen">
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ p: 0.6, ml: 0.5 }}>
|
||||||
|
<CloseRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Preview Content */}
|
||||||
|
{file?.isImage && this.renderImagePreview(vfsUrl)}
|
||||||
|
{file?.isPdf && this.renderPdfPreview(vfsUrl, fileName)}
|
||||||
|
{!file?.isImage && !file?.isPdf && this.renderBinaryPreview(fileName, file)}
|
||||||
|
|
||||||
|
{/* Footer info bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 0.4,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
color: 'text.secondary',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{formatVfsSize(file?.size)}</span>
|
||||||
|
<span>{file?.mime || 'application/octet-stream'}</span>
|
||||||
|
{file?.updated_at && <span>{formatVfsDate(file.updated_at)}</span>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/components/vfs/VfsNewFileDialog.jsx
Normal file
151
src/components/vfs/VfsNewFileDialog.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded';
|
||||||
|
|
||||||
|
const COMMON_EXTENSIONS = [
|
||||||
|
{ ext: '.txt', label: 'Text (.txt)' },
|
||||||
|
{ ext: '.md', label: 'Markdown (.md)' },
|
||||||
|
{ ext: '.csv', label: 'CSV (.csv)' },
|
||||||
|
{ ext: '.json', label: 'JSON (.json)' },
|
||||||
|
{ ext: '.js', label: 'JavaScript (.js)' },
|
||||||
|
{ ext: '.html', label: 'HTML (.html)' },
|
||||||
|
{ ext: '.SKILL', label: 'Agent Skill (.SKILL)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default class VfsNewFileDialog extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
filename: '',
|
||||||
|
error: '',
|
||||||
|
};
|
||||||
|
this.inputRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (!prevProps.open && this.props.open) {
|
||||||
|
this.setState({ filename: '', error: '' }, () => {
|
||||||
|
setTimeout(() => this.inputRef.current?.focus(), 80);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleExtensionClick = (ext) => {
|
||||||
|
let name = this.state.filename.trim();
|
||||||
|
if (!name) {
|
||||||
|
name = `neue_datei${ext}`;
|
||||||
|
} else {
|
||||||
|
// If it already has an extension, replace it
|
||||||
|
const lastDot = name.lastIndexOf('.');
|
||||||
|
if (lastDot > 0) {
|
||||||
|
name = name.slice(0, lastDot) + ext;
|
||||||
|
} else {
|
||||||
|
name = name + ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setState({ filename: name, error: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const clean = this.state.filename.trim().replace(/^[\/\\]+/, '');
|
||||||
|
if (!clean) {
|
||||||
|
this.setState({ error: 'Bitte gib einen Dateinamen ein.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clean.includes('..') || clean.includes('\\')) {
|
||||||
|
this.setState({ error: 'Ungültige Zeichen im Dateinamen.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.props.onCreate(clean);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { open, onClose, isCreating } = this.props;
|
||||||
|
const { filename, error } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: { borderRadius: 3, p: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.2, pb: 1 }}>
|
||||||
|
<NoteAddRoundedIcon color="primary" />
|
||||||
|
<Typography variant="h6" fontWeight={700}>
|
||||||
|
Neue VFS-Datei erstellen
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<DialogContent sx={{ pt: 1 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Dateien werden im virtuellen Dateisystem des aktuellen Mandanten gespeichert.
|
||||||
|
Verwende ggf. Unterordner wie <code>scripts/test.js</code>.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
inputRef={this.inputRef}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Dateiname oder Pfad"
|
||||||
|
placeholder="z.B. rechnung.csv oder docs/info.md"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => this.setState({ filename: e.target.value, error: '' })}
|
||||||
|
error={Boolean(error)}
|
||||||
|
helperText={error}
|
||||||
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1, fontWeight: 600 }}>
|
||||||
|
Häufige Dateitypen:
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||||
|
{COMMON_EXTENSIONS.map((item) => (
|
||||||
|
<Chip
|
||||||
|
key={item.ext}
|
||||||
|
label={item.ext}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
clickable
|
||||||
|
onClick={() => this.handleExtensionClick(item.ext)}
|
||||||
|
sx={{ fontSize: '0.75rem', fontFamily: 'monospace', borderRadius: 1.5 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
|
||||||
|
<Button onClick={onClose} disabled={isCreating} color="inherit">
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isCreating || !filename.trim()}
|
||||||
|
sx={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
{isCreating ? 'Erstelle…' : 'Erstellen & Öffnen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
386
src/components/vfs/VfsTextEditor.jsx
Normal file
386
src/components/vfs/VfsTextEditor.jsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import SaveRoundedIcon from '@mui/icons-material/SaveRounded';
|
||||||
|
import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
|
||||||
|
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
|
import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
|
||||||
|
import VfsFileIcon from './VfsFileIcon';
|
||||||
|
import { formatVfsSize } from './vfsUtils';
|
||||||
|
|
||||||
|
export default class VfsTextEditor extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.textareaRef = React.createRef();
|
||||||
|
this.gutterRef = React.createRef();
|
||||||
|
this.state = {
|
||||||
|
wrapLines: true,
|
||||||
|
cursorLine: 1,
|
||||||
|
cursorCol: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.fileName !== this.props.fileName) {
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusEditor = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.textareaRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleScroll = () => {
|
||||||
|
if (this.textareaRef.current && this.gutterRef.current) {
|
||||||
|
this.gutterRef.current.scrollTop = this.textareaRef.current.scrollTop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleWrap = () => {
|
||||||
|
this.setState((prev) => ({ wrapLines: !prev.wrapLines }));
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCursorMove = () => {
|
||||||
|
const ta = this.textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const pos = ta.selectionStart || 0;
|
||||||
|
const textBefore = (this.props.content || '').substring(0, pos);
|
||||||
|
const lines = textBefore.split('\n');
|
||||||
|
const line = lines.length;
|
||||||
|
const col = lines[lines.length - 1].length + 1;
|
||||||
|
if (this.state.cursorLine !== line || this.state.cursorCol !== col) {
|
||||||
|
this.setState({ cursorLine: line, cursorCol: col });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = (e) => {
|
||||||
|
// Ctrl+S or Cmd+S -> Save
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.props.isDirty && !this.props.isSaving) {
|
||||||
|
this.props.onSave();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab key -> Insert 2 spaces
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const ta = this.textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const val = this.props.content;
|
||||||
|
const next = val.substring(0, start) + ' ' + val.substring(end);
|
||||||
|
this.props.onChange(next);
|
||||||
|
setTimeout(() => {
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRevert = () => {
|
||||||
|
if (window.confirm('Möchtest du alle ungespeicherten Änderungen verwerfen?')) {
|
||||||
|
this.props.onChange(this.props.originalContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderLineNumbers(lineCount) {
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 1; i <= lineCount; i++) {
|
||||||
|
lines.push(
|
||||||
|
<div key={i} style={{ height: '21px', lineHeight: '21px' }}>
|
||||||
|
{i}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
fileName,
|
||||||
|
file,
|
||||||
|
content,
|
||||||
|
isDirty,
|
||||||
|
isSaving,
|
||||||
|
saveError,
|
||||||
|
loadingContent,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
onDownload,
|
||||||
|
onMentionInChat,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const lineCount = (content || '').split('\n').length;
|
||||||
|
const charCount = (content || '').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? '#14171a' : '#f8f9fa',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Editor Top Bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.25,
|
||||||
|
px: 2,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
flexShrink: 0,
|
||||||
|
minHeight: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* File Name & Status */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0, mr: 1 }}>
|
||||||
|
<Tooltip title="Zurück zur Dateiliste">
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ p: 0.5, mr: 0.25 }}>
|
||||||
|
<ArrowBackRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<VfsFileIcon fileName={fileName} mime={file?.mime} fontSize="small" />
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={700}
|
||||||
|
noWrap
|
||||||
|
sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
{fileName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isDirty ? (
|
||||||
|
<Chip
|
||||||
|
label="Ungespeichert"
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label="Gespeichert"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', borderRadius: 1, opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexShrink: 0 }}>
|
||||||
|
{onMentionInChat && (
|
||||||
|
<Tooltip title="In Chat erwähnen">
|
||||||
|
<IconButton size="small" onClick={onMentionInChat} sx={{ p: 0.6 }}>
|
||||||
|
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip title="Herunterladen">
|
||||||
|
<IconButton size="small" onClick={onDownload} sx={{ p: 0.6 }}>
|
||||||
|
<DownloadRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{isDirty && (
|
||||||
|
<Tooltip title="Änderungen verwerfen">
|
||||||
|
<IconButton size="small" onClick={this.handleRevert} sx={{ p: 0.6, color: 'text.secondary' }}>
|
||||||
|
<UndoRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip title={this.state.wrapLines ? 'Zeilenumbruch ausschalten' : 'Zeilenumbruch einschalten'}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={this.toggleWrap}
|
||||||
|
color={this.state.wrapLines ? 'primary' : 'default'}
|
||||||
|
sx={{
|
||||||
|
p: 0.6,
|
||||||
|
bgcolor: this.state.wrapLines ? 'action.selected' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WrapTextRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color={isDirty ? 'primary' : 'inherit'}
|
||||||
|
startIcon={isSaving ? <CircularProgress size={14} color="inherit" /> : <SaveRoundedIcon />}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!isDirty || isSaving}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.4,
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Speichern…' : 'Speichern (Strg+S)'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Tooltip title="Editor schließen">
|
||||||
|
<IconButton size="small" onClick={onClose} sx={{ p: 0.6, ml: 0.5 }}>
|
||||||
|
<CloseRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error notification if save failed */}
|
||||||
|
{saveError && (
|
||||||
|
<Alert severity="error" sx={{ py: 0.5, px: 2, borderRadius: 0, fontSize: '0.78rem' }}>
|
||||||
|
{saveError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editor Area */}
|
||||||
|
<Box sx={{ flexGrow: 1, display: 'flex', minHeight: 0, position: 'relative' }}>
|
||||||
|
{loadingContent ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
<Typography variant="body2">Lade Dateiinhalt…</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Line Numbers Gutter (shown when wrap is off to maintain line alignment) */}
|
||||||
|
{!this.state.wrapLines && (
|
||||||
|
<Box
|
||||||
|
ref={this.gutterRef}
|
||||||
|
sx={(theme) => ({
|
||||||
|
width: 44,
|
||||||
|
flexShrink: 0,
|
||||||
|
bgcolor: theme.palette.mode === 'dark' ? '#0f1215' : '#f0f2f5',
|
||||||
|
color: 'text.disabled',
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
textAlign: 'right',
|
||||||
|
pr: 1.2,
|
||||||
|
pt: 1.5,
|
||||||
|
pb: 3,
|
||||||
|
userSelect: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRight: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{this.renderLineNumbers(lineCount)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Textarea Code Input */}
|
||||||
|
<Box
|
||||||
|
component="textarea"
|
||||||
|
ref={this.textareaRef}
|
||||||
|
value={content || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
this.props.onChange(e.target.value);
|
||||||
|
this.handleCursorMove();
|
||||||
|
}}
|
||||||
|
onClick={this.handleCursorMove}
|
||||||
|
onKeyUp={this.handleCursorMove}
|
||||||
|
onSelect={this.handleCursorMove}
|
||||||
|
onScroll={this.handleScroll}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
wrap={this.state.wrapLines ? 'soft' : 'off'}
|
||||||
|
sx={(theme) => ({
|
||||||
|
flexGrow: 1,
|
||||||
|
height: '100%',
|
||||||
|
p: 1.5,
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
color: 'text.primary',
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
lineHeight: '21px',
|
||||||
|
tabSize: 2,
|
||||||
|
whiteSpace: this.state.wrapLines ? 'pre-wrap' : 'pre',
|
||||||
|
wordBreak: this.state.wrapLines ? 'break-word' : 'normal',
|
||||||
|
overflowWrap: this.state.wrapLines ? 'anywhere' : 'normal',
|
||||||
|
overflowX: this.state.wrapLines ? 'hidden' : 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
'&:focus': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status Bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 0.4,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
color: 'text.secondary',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<span>
|
||||||
|
Zeile {this.state.cursorLine}, Spalte {this.state.cursorCol}
|
||||||
|
</span>
|
||||||
|
<span>{lineCount} Zeilen</span>
|
||||||
|
<span>{charCount} Zeichen</span>
|
||||||
|
<span>{formatVfsSize(charCount)}</span>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
<span>{file?.mime || 'text/plain'}</span>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/components/vfs/index.js
Normal file
6
src/components/vfs/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as VfsEditorPanel } from './VfsEditorPanel';
|
||||||
|
export { default as VfsFileExplorer } from './VfsFileExplorer';
|
||||||
|
export { default as VfsTextEditor } from './VfsTextEditor';
|
||||||
|
export { default as VfsFilePreview } from './VfsFilePreview';
|
||||||
|
export { default as VfsFileIcon } from './VfsFileIcon';
|
||||||
|
export * from './vfsUtils';
|
||||||
97
src/components/vfs/vfsUtils.js
Normal file
97
src/components/vfs/vfsUtils.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @file vfsUtils.js
|
||||||
|
* @description Helper functions for VFS file types, formatting, and path manipulation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human readable string.
|
||||||
|
* @param {number} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatVfsSize(bytes) {
|
||||||
|
if (bytes == null || Number.isNaN(bytes) || bytes < 0) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date string into local compact date/time.
|
||||||
|
* @param {string} isoString
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatVfsDate(isoString) {
|
||||||
|
if (!isoString) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString([], {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect category of file.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @param {string} [mime]
|
||||||
|
* @returns {'code'|'table'|'markdown'|'text'|'pdf'|'image'|'skill'|'binary'}
|
||||||
|
*/
|
||||||
|
export function getVfsCategory(filePath, mime = '') {
|
||||||
|
const p = String(filePath || '').toLowerCase();
|
||||||
|
const m = String(mime || '').toLowerCase();
|
||||||
|
|
||||||
|
if (p.endsWith('.skill')) return 'skill';
|
||||||
|
if (p.endsWith('.pdf') || m === 'application/pdf') return 'pdf';
|
||||||
|
if (
|
||||||
|
m.startsWith('image/') ||
|
||||||
|
/\.(png|jpe?g|webp|gif|svg|avif|bmp|ico)$/.test(p)
|
||||||
|
) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
p.endsWith('.csv') ||
|
||||||
|
p.endsWith('.tsv') ||
|
||||||
|
p.endsWith('.xlsx') ||
|
||||||
|
p.endsWith('.xls') ||
|
||||||
|
m.includes('csv') ||
|
||||||
|
m.includes('spreadsheet')
|
||||||
|
) {
|
||||||
|
return 'table';
|
||||||
|
}
|
||||||
|
if (p.endsWith('.md')) return 'markdown';
|
||||||
|
if (
|
||||||
|
/\.(js|mjs|cjs|ts|tsx|jsx|json|html|css|scss|xml|sql|py|sh|yaml|yml|env)$/.test(p) ||
|
||||||
|
m === 'application/json' ||
|
||||||
|
m === 'application/javascript'
|
||||||
|
) {
|
||||||
|
return 'code';
|
||||||
|
}
|
||||||
|
if (m.startsWith('text/') || /\.(txt|log|ini|conf)$/.test(p)) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
return 'binary';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a VFS file path into directory and basename.
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {{ dir: string, base: string }}
|
||||||
|
*/
|
||||||
|
export function splitVfsPath(path) {
|
||||||
|
const clean = String(path || '').replace(/^[\/\\]+/, '');
|
||||||
|
const lastSlash = clean.lastIndexOf('/');
|
||||||
|
if (lastSlash === -1) {
|
||||||
|
return { dir: '', base: clean };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dir: clean.slice(0, lastSlash),
|
||||||
|
base: clean.slice(lastSlash + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user