Files
tsview/ts_web/elements/tsview-s3-columns.ts

1715 lines
51 KiB
TypeScript

import * as plugins from '../plugins.js';
import { apiService, type IS3Object } from '../services/index.js';
import { getFileName, validateMove, getParentPrefix } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
// FileSystem API types for drag-and-drop folder support
interface FileSystemEntry {
isFile: boolean;
isDirectory: boolean;
name: string;
}
interface FileSystemFileEntry extends FileSystemEntry {
file(successCallback: (file: File) => void, errorCallback?: (error: Error) => void): void;
}
interface FileSystemDirectoryEntry extends FileSystemEntry {
createReader(): FileSystemDirectoryReader;
}
interface FileSystemDirectoryReader {
readEntries(
successCallback: (entries: FileSystemEntry[]) => void,
errorCallback?: (error: Error) => void
): void;
}
interface IColumn {
prefix: string;
objects: IS3Object[];
prefixes: string[];
selectedItem: string | null;
width: number;
}
@customElement('tsview-s3-columns')
export class TsviewS3Columns extends DeesElement {
@property({ type: String })
public accessor bucketName: string = '';
@property({ type: String })
public accessor currentPrefix: string = '';
@property({ type: Number })
public accessor refreshKey: number = 0;
@state()
private accessor columns: IColumn[] = [];
@state()
private accessor loading: boolean = false;
@state()
private accessor showCreateDialog: boolean = false;
@state()
private accessor createDialogType: 'folder' | 'file' = 'folder';
@state()
private accessor createDialogPrefix: string = '';
@state()
private accessor createDialogName: string = '';
@state()
private accessor dragOverColumnIndex: number = -1;
@state()
private accessor dragOverFolderPrefix: string | null = null;
@state()
private accessor uploading: boolean = false;
@state()
private accessor uploadProgress: { current: number; total: number } | null = null;
// Move dialog state
@state()
private accessor showMoveDialog: boolean = false;
@state()
private accessor moveSource: { key: string; isFolder: boolean } | null = null;
@state()
private accessor moveDestination: string = '';
@state()
private accessor moveInProgress: boolean = false;
@state()
private accessor moveError: string | null = null;
// Move picker dialog state
@state()
private accessor showMovePickerDialog: boolean = false;
@state()
private accessor movePickerSource: { key: string; isFolder: boolean } | null = null;
@state()
private accessor movePickerCurrentPrefix: string = '';
@state()
private accessor movePickerPrefixes: string[] = [];
@state()
private accessor movePickerLoading: boolean = false;
// Rename dialog state
@state()
private accessor showRenameDialog: boolean = false;
@state()
private accessor renameSource: { key: string; isFolder: boolean } | null = null;
@state()
private accessor renameName: string = '';
@state()
private accessor renameInProgress: boolean = false;
@state()
private accessor renameError: string | null = null;
// Internal drag state
@state()
private accessor draggedItem: { key: string; isFolder: boolean } | null = null;
private resizing: { columnIndex: number; startX: number; startWidth: number } | null = null;
private readonly DEFAULT_COLUMN_WIDTH = 250;
private readonly MIN_COLUMN_WIDTH = 150;
private readonly MAX_COLUMN_WIDTH = 500;
private dragCounters: Map<number, number> = new Map();
private folderHoverTimer: ReturnType<typeof setTimeout> | null = null;
private fileInputElement: HTMLInputElement | null = null;
private folderInputElement: HTMLInputElement | null = null;
public static styles = [
cssManager.defaultStyles,
themeStyles,
css`
:host {
display: block;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
}
.columns-container {
display: flex;
height: 100%;
min-width: max-content;
}
.column-wrapper {
display: flex;
height: 100%;
flex-shrink: 0;
}
.column {
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.resize-handle {
width: 5px;
height: 100%;
background: transparent;
cursor: col-resize;
position: relative;
flex-shrink: 0;
}
.resize-handle::after {
content: '';
position: absolute;
top: 0;
left: 2px;
width: 1px;
height: 100%;
background: #333;
}
.resize-handle:hover::after,
.resize-handle.active::after {
background: #404040;
width: 2px;
left: 1px;
}
.column-wrapper:last-child .resize-handle {
display: none;
}
.column-header {
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: #666;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column-items {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.column-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
.column-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.column-item.selected {
background: rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
.column-item.folder {
color: #fbbf24;
}
.column-item .icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.column-item .name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.column-item .chevron {
width: 14px;
height: 14px;
color: #555;
}
.empty-state {
padding: 16px;
text-align: center;
color: #666;
font-size: 13px;
}
.loading {
padding: 16px;
text-align: center;
color: #666;
}
.column.drag-over {
background: rgba(59, 130, 246, 0.08);
outline: 2px dashed rgba(59, 130, 246, 0.4);
outline-offset: -2px;
}
.column-item.folder.drag-target {
background: rgba(59, 130, 246, 0.2) !important;
outline: 1px solid rgba(59, 130, 246, 0.5);
}
.upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
z-index: 10;
border-radius: 4px;
}
.upload-overlay .upload-text {
color: #e0e0e0;
font-size: 13px;
}
.upload-overlay .upload-progress {
color: #888;
font-size: 11px;
}
.drag-hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
pointer-events: none;
white-space: nowrap;
z-index: 5;
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #1e1e1e;
border-radius: 12px;
padding: 24px;
min-width: 400px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
}
.dialog-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #fff;
}
.dialog-location {
font-size: 12px;
color: #888;
margin-bottom: 12px;
font-family: monospace;
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: #141414;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 14px;
margin-bottom: 8px;
box-sizing: border-box;
}
.dialog-input:focus {
outline: none;
border-color: #e0e0e0;
}
.dialog-hint {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.dialog-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dialog-btn-cancel {
background: transparent;
border: 1px solid #444;
color: #aaa;
}
.dialog-btn-cancel:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.dialog-btn-create {
background: #404040;
border: none;
color: #fff;
}
.dialog-btn-create:hover {
background: #505050;
}
.dialog-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Move dialog styles */
.move-summary {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.move-item {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.move-item:last-child {
margin-bottom: 0;
}
.move-label {
color: #888;
min-width: 40px;
}
.move-path {
color: #e0e0e0;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.move-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
}
/* Move picker dialog styles */
.move-picker-dialog {
min-width: 450px;
max-height: 500px;
}
.picker-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
margin-bottom: 12px;
font-size: 12px;
overflow-x: auto;
}
.picker-crumb {
color: #888;
cursor: pointer;
white-space: nowrap;
}
.picker-crumb:hover {
color: #fff;
}
.picker-separator {
color: #555;
}
.picker-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid #333;
border-radius: 6px;
margin-bottom: 16px;
min-height: 100px;
}
.picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
color: #fbbf24;
}
.picker-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.picker-item .icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.picker-item .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-empty {
padding: 24px;
text-align: center;
color: #666;
font-size: 13px;
}
.column-item.dragging {
opacity: 0.5;
}
.column-item.drop-target {
background: rgba(59, 130, 246, 0.15) !important;
outline: 1px dashed rgba(59, 130, 246, 0.5);
}
.column-item.drop-invalid {
background: rgba(239, 68, 68, 0.1) !important;
cursor: not-allowed;
}
`,
];
async connectedCallback() {
super.connectedCallback();
await this.loadInitialColumn();
}
disconnectedCallback() {
super.disconnectedCallback();
this.clearFolderHover();
this.dragCounters.clear();
if (this.fileInputElement) { this.fileInputElement.remove(); this.fileInputElement = null; }
if (this.folderInputElement) { this.folderInputElement.remove(); this.folderInputElement = null; }
}
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('bucketName') || changedProperties.has('currentPrefix')) {
this.loadInitialColumn();
} else if (changedProperties.has('refreshKey')) {
this.refreshAllColumns();
}
}
private async loadInitialColumn() {
this.loading = true;
try {
// Parse the path segments from currentPrefix
// e.g., "folder1/folder2/folder3/" → ["folder1/", "folder1/folder2/", "folder1/folder2/folder3/"]
const pathSegments = this.getPathSegments(this.currentPrefix);
// Build all prefixes we need to load (including root)
const prefixesToLoad = ['', ...pathSegments];
// Load all columns in parallel
const columnResults = await Promise.all(
prefixesToLoad.map(prefix =>
apiService.listObjects(this.bucketName, prefix, '/')
)
);
// Build columns array with proper selections
this.columns = columnResults.map((result, index) => {
const prefix = prefixesToLoad[index];
// The selected item is the next prefix in the path (if any)
const selectedItem = index < pathSegments.length ? pathSegments[index] : null;
return {
prefix,
objects: result.objects,
prefixes: result.prefixes,
selectedItem,
width: this.DEFAULT_COLUMN_WIDTH,
};
});
// Auto-scroll to show the rightmost column
this.updateComplete.then(() => this.scrollToEnd());
} catch (err) {
console.error('Error loading objects:', err);
this.columns = [];
}
this.loading = false;
}
// Helper to parse prefix into cumulative path segments
private getPathSegments(prefix: string): string[] {
if (!prefix) return [];
const parts = prefix.split('/').filter(p => p); // Remove empty strings
const segments: string[] = [];
let cumulative = '';
for (const part of parts) {
cumulative += part + '/';
segments.push(cumulative);
}
return segments;
}
private async refreshAllColumns() {
const updatedColumns = await Promise.all(
this.columns.map(async (col) => {
try {
const result = await apiService.listObjects(this.bucketName, col.prefix, '/');
return { ...col, objects: result.objects, prefixes: result.prefixes };
} catch {
return col;
}
})
);
this.columns = updatedColumns;
}
private async selectFolder(columnIndex: number, prefix: string) {
// Update selection in current column
this.columns = this.columns.map((col, i) => {
if (i === columnIndex) {
return { ...col, selectedItem: prefix };
}
return col;
});
// Remove columns after current
this.columns = this.columns.slice(0, columnIndex + 1);
// Load new column
try {
const result = await apiService.listObjects(this.bucketName, prefix, '/');
this.columns = [
...this.columns,
{
prefix,
objects: result.objects,
prefixes: result.prefixes,
selectedItem: null,
width: this.DEFAULT_COLUMN_WIDTH,
},
];
// Auto-scroll to show the new column
this.updateComplete.then(() => this.scrollToEnd());
} catch (err) {
console.error('Error loading folder:', err);
}
// Note: Don't dispatch navigate event here - columns view expands horizontally
// The navigate event is only for breadcrumb sync, not for column navigation
}
private scrollToEnd() {
this.scrollLeft = this.scrollWidth - this.clientWidth;
}
private startResize(e: MouseEvent, columnIndex: number) {
e.preventDefault();
this.resizing = {
columnIndex,
startX: e.clientX,
startWidth: this.columns[columnIndex].width,
};
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.stopResize);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
private handleResize = (e: MouseEvent) => {
if (!this.resizing) return;
const delta = e.clientX - this.resizing.startX;
const newWidth = Math.min(
this.MAX_COLUMN_WIDTH,
Math.max(this.MIN_COLUMN_WIDTH, this.resizing.startWidth + delta)
);
this.columns = this.columns.map((col, i) => {
if (i === this.resizing!.columnIndex) {
return { ...col, width: newWidth };
}
return col;
});
};
private stopResize = () => {
this.resizing = null;
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.stopResize);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
private selectFile(columnIndex: number, key: string) {
// Update selection
this.columns = this.columns.map((col, i) => {
if (i === columnIndex) {
return { ...col, selectedItem: key };
}
return col;
});
// Remove columns after current
this.columns = this.columns.slice(0, columnIndex + 1);
// Dispatch key-selected event
this.dispatchEvent(
new CustomEvent('key-selected', {
detail: { key },
bubbles: true,
composed: true,
})
);
}
private getFileIcon(key: string): string {
const ext = key.split('.').pop()?.toLowerCase() || '';
const iconMap: Record<string, string> = {
json: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
txt: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
png: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
jpg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
jpeg: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
gif: 'M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z',
pdf: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z',
};
return iconMap[ext] || 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
}
private handleFolderContextMenu(event: MouseEvent, columnIndex: number, prefix: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Open',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectFolder(columnIndex, prefix);
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(prefix);
},
},
{
name: 'Rename',
iconName: 'lucide:pencil',
action: async () => this.openRenameDialog(prefix, true),
},
{ divider: true },
{
name: 'Move to...',
iconName: 'lucide:folderInput',
action: async () => this.openMovePickerDialog(prefix, true),
},
{ divider: true },
{
name: 'New Folder Inside',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File Inside',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
{
name: 'Upload Files...',
iconName: 'lucide:file',
action: async () => this.triggerFileUpload(prefix, 'files'),
},
{
name: 'Upload Folder...',
iconName: 'lucide:folderUp',
action: async () => this.triggerFileUpload(prefix, 'folder'),
},
{ divider: true },
{
name: 'Delete Folder',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete folder "${getFileName(prefix)}" and all its contents?`)) {
const success = await apiService.deletePrefix(this.bucketName, prefix);
if (success) {
// Remove columns that were inside the deleted folder
this.columns = this.columns.slice(0, columnIndex + 1);
this.columns = this.columns.map((col, i) =>
i === columnIndex ? { ...col, selectedItem: null } : col
);
await this.refreshColumnByPrefix(this.columns[columnIndex].prefix);
}
}
},
},
]);
}
private handleFileContextMenu(event: MouseEvent, columnIndex: number, key: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Preview',
iconName: 'lucide:eye',
action: async () => {
this.selectFile(columnIndex, key);
},
},
{
name: 'Download',
iconName: 'lucide:download',
action: async () => {
const url = await apiService.getObjectUrl(this.bucketName, key);
const link = document.createElement('a');
link.href = url;
link.download = getFileName(key);
link.click();
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{
name: 'Rename',
iconName: 'lucide:pencil',
action: async () => this.openRenameDialog(key, false),
},
{ divider: true },
{
name: 'Move to...',
iconName: 'lucide:folderInput',
action: async () => this.openMovePickerDialog(key, false),
},
{ divider: true },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete file "${getFileName(key)}"?`)) {
const success = await apiService.deleteObject(this.bucketName, key);
if (success) {
await this.refreshColumnByPrefix(this.columns[columnIndex].prefix);
}
}
},
},
]);
}
private handleEmptySpaceContextMenu(event: MouseEvent, columnIndex: number) {
// Only trigger if clicking on the container itself, not on items
if (event.target !== event.currentTarget) return;
event.preventDefault();
const prefix = this.columns[columnIndex].prefix;
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Folder',
iconName: 'lucide:folderPlus',
action: async () => this.openCreateDialog('folder', prefix),
},
{
name: 'New File',
iconName: 'lucide:filePlus',
action: async () => this.openCreateDialog('file', prefix),
},
{
name: 'Upload Files...',
iconName: 'lucide:file',
action: async () => this.triggerFileUpload(prefix, 'files'),
},
{
name: 'Upload Folder...',
iconName: 'lucide:folderUp',
action: async () => this.triggerFileUpload(prefix, 'folder'),
},
]);
}
private openCreateDialog(type: 'folder' | 'file', prefix: string) {
this.createDialogType = type;
this.createDialogPrefix = prefix;
this.createDialogName = '';
this.showCreateDialog = true;
}
private getContentType(ext: string): string {
const contentTypes: Record<string, string> = {
json: 'application/json',
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'application/javascript',
ts: 'text/typescript',
md: 'text/markdown',
xml: 'application/xml',
yaml: 'text/yaml',
yml: 'text/yaml',
csv: 'text/csv',
};
return contentTypes[ext] || 'application/octet-stream';
}
private getDefaultContent(ext: string): string {
const defaults: Record<string, string> = {
json: '{\n \n}',
html: '<!DOCTYPE html>\n<html>\n<head>\n <title></title>\n</head>\n<body>\n \n</body>\n</html>',
md: '# Title\n\n',
txt: '',
};
return defaults[ext] || '';
}
// --- File upload helpers ---
private ensureFileInputs(): void {
if (!this.fileInputElement) {
this.fileInputElement = document.createElement('input');
this.fileInputElement.type = 'file';
this.fileInputElement.multiple = true;
this.fileInputElement.style.display = 'none';
this.shadowRoot!.appendChild(this.fileInputElement);
}
if (!this.folderInputElement) {
this.folderInputElement = document.createElement('input');
this.folderInputElement.type = 'file';
this.folderInputElement.multiple = true;
(this.folderInputElement as any).webkitdirectory = true;
this.folderInputElement.style.display = 'none';
this.shadowRoot!.appendChild(this.folderInputElement);
}
}
private triggerFileUpload(targetPrefix: string, type: 'files' | 'folder') {
this.ensureFileInputs();
const input = type === 'folder' ? this.folderInputElement! : this.fileInputElement!;
input.value = '';
const handler = async () => {
input.removeEventListener('change', handler);
if (input.files && input.files.length > 0) {
await this.uploadFiles(Array.from(input.files), targetPrefix);
}
};
input.addEventListener('change', handler);
input.click();
}
private async uploadFiles(files: File[], targetPrefix: string) {
this.uploading = true;
this.uploadProgress = { current: 0, total: files.length };
for (let i = 0; i < files.length; i++) {
const file = files[i];
this.uploadProgress = { current: i + 1, total: files.length };
try {
const base64 = await this.readFileAsBase64(file);
// Use webkitRelativePath if available (folder upload), otherwise just filename
const relativePath = (file as any).webkitRelativePath || file.name;
const key = targetPrefix + relativePath;
const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || '');
await apiService.putObject(this.bucketName, key, base64, contentType);
} catch (err) {
console.error(`Failed to upload ${file.name}:`, err);
}
}
this.uploading = false;
this.uploadProgress = null;
await this.refreshColumnByPrefix(targetPrefix);
}
private async uploadFilesWithPaths(
files: { file: File; relativePath: string }[],
targetPrefix: string
) {
this.uploading = true;
this.uploadProgress = { current: 0, total: files.length };
for (let i = 0; i < files.length; i++) {
const { file, relativePath } = files[i];
this.uploadProgress = { current: i + 1, total: files.length };
try {
const base64 = await this.readFileAsBase64(file);
const key = targetPrefix + relativePath;
const contentType = file.type || this.getContentType(file.name.split('.').pop()?.toLowerCase() || '');
await apiService.putObject(this.bucketName, key, base64, contentType);
} catch (err) {
console.error(`Failed to upload ${relativePath}:`, err);
}
}
this.uploading = false;
this.uploadProgress = null;
await this.refreshColumnByPrefix(targetPrefix);
}
private readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(',')[1] || '');
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
private async refreshColumnByPrefix(prefix: string) {
const columnIndex = this.columns.findIndex(col => col.prefix === prefix);
if (columnIndex === -1) {
await this.refreshAllColumns();
return;
}
try {
const result = await apiService.listObjects(this.bucketName, prefix, '/');
this.columns = this.columns.map((col, i) =>
i === columnIndex ? { ...col, objects: result.objects, prefixes: result.prefixes } : col
);
} catch {
await this.refreshAllColumns();
}
}
// --- Drag-and-drop handlers ---
private handleColumnDragEnter(e: DragEvent, columnIndex: number) {
e.preventDefault();
e.stopPropagation();
const count = (this.dragCounters.get(columnIndex) || 0) + 1;
this.dragCounters.set(columnIndex, count);
this.dragOverColumnIndex = columnIndex;
}
private handleColumnDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = this.draggedItem ? 'move' : 'copy';
}
}
private handleColumnDragLeave(e: DragEvent, columnIndex: number) {
e.stopPropagation();
const count = (this.dragCounters.get(columnIndex) || 1) - 1;
this.dragCounters.set(columnIndex, count);
if (count <= 0) {
this.dragCounters.delete(columnIndex);
if (this.dragOverColumnIndex === columnIndex) this.dragOverColumnIndex = -1;
this.clearFolderHover();
}
}
private async handleColumnDrop(e: DragEvent, columnIndex: number) {
e.preventDefault();
e.stopPropagation();
const targetPrefix = this.dragOverFolderPrefix ?? this.columns[columnIndex].prefix;
// Check if this is an internal move (item from within the app)
if (this.draggedItem) {
this.initiateMove(this.draggedItem.key, this.draggedItem.isFolder, targetPrefix);
this.draggedItem = null;
this.clearFolderHover();
this.dragCounters.clear();
this.dragOverColumnIndex = -1;
return;
}
this.dragCounters.clear();
this.dragOverColumnIndex = -1;
const items = e.dataTransfer?.items;
if (!items || items.length === 0) return;
this.clearFolderHover();
// Collect all files (including from nested folders)
const allFiles: { file: File; relativePath: string }[] = [];
const processEntry = async (entry: FileSystemEntry, path: string): Promise<void> => {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
const file = await new Promise<File>((resolve, reject) => {
fileEntry.file(resolve, reject);
});
allFiles.push({ file, relativePath: path + file.name });
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const reader = dirEntry.createReader();
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
for (const childEntry of entries) {
await processEntry(childEntry, path + entry.name + '/');
}
}
};
// Process all dropped items
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const entry = (item as any).webkitGetAsEntry();
if (entry) {
await processEntry(entry, '');
}
}
}
if (allFiles.length > 0) {
await this.uploadFilesWithPaths(allFiles, targetPrefix);
}
}
private handleFolderDragEnter(e: DragEvent, folderPrefix: string) {
e.stopPropagation();
this.clearFolderHover();
this.folderHoverTimer = setTimeout(() => {
this.dragOverFolderPrefix = folderPrefix;
}, 500);
}
private handleFolderDragLeave(e: DragEvent, folderPrefix: string) {
e.stopPropagation();
// Check if we're leaving to a child element - if so, don't cancel the hover
const target = e.currentTarget as HTMLElement;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && target.contains(relatedTarget)) {
return; // Still inside the folder, ignore this dragleave
}
if (this.dragOverFolderPrefix === folderPrefix) this.dragOverFolderPrefix = null;
if (this.folderHoverTimer) {
clearTimeout(this.folderHoverTimer);
this.folderHoverTimer = null;
}
}
private handleFolderDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = this.draggedItem ? 'move' : 'copy';
}
}
private clearFolderHover() {
if (this.folderHoverTimer) { clearTimeout(this.folderHoverTimer); this.folderHoverTimer = null; }
this.dragOverFolderPrefix = null;
}
// --- Internal drag handlers for move ---
private handleItemDragStart(e: DragEvent, key: string, isFolder: boolean) {
this.draggedItem = { key, isFolder };
e.dataTransfer?.setData('text/plain', key);
e.dataTransfer!.effectAllowed = 'copyMove'; // Allow both for flexibility
// Add visual feedback to dragged item
const target = e.target as HTMLElement;
target.classList.add('dragging');
}
private handleItemDragEnd(e: DragEvent) {
this.draggedItem = null;
const target = e.target as HTMLElement;
target.classList.remove('dragging');
}
// --- Move picker dialog ---
private async openMovePickerDialog(key: string, isFolder: boolean) {
this.movePickerSource = { key, isFolder };
this.movePickerCurrentPrefix = '';
this.showMovePickerDialog = true;
await this.loadMovePickerPrefixes('');
}
private async navigateMovePicker(prefix: string) {
this.movePickerCurrentPrefix = prefix;
await this.loadMovePickerPrefixes(prefix);
}
private async loadMovePickerPrefixes(prefix: string) {
this.movePickerLoading = true;
try {
const result = await apiService.listObjects(this.bucketName, prefix, '/');
this.movePickerPrefixes = result.prefixes;
} catch {
this.movePickerPrefixes = [];
}
this.movePickerLoading = false;
}
private selectMoveDestination(destPrefix: string) {
if (!this.movePickerSource) return;
this.closeMovePickerDialog();
this.initiateMove(this.movePickerSource.key, this.movePickerSource.isFolder, destPrefix);
}
private closeMovePickerDialog() {
this.showMovePickerDialog = false;
this.movePickerSource = null;
this.movePickerCurrentPrefix = '';
this.movePickerPrefixes = [];
}
// --- Move confirmation dialog ---
private initiateMove(sourceKey: string, isFolder: boolean, destPrefix: string) {
const validation = validateMove(sourceKey, destPrefix);
if (!validation.valid) {
this.moveSource = { key: sourceKey, isFolder };
this.moveDestination = destPrefix;
this.moveError = validation.error!;
this.showMoveDialog = true;
return;
}
this.moveSource = { key: sourceKey, isFolder };
this.moveDestination = destPrefix;
this.moveError = null;
this.showMoveDialog = true;
}
private async executeMove() {
if (!this.moveSource) return;
this.moveInProgress = true;
try {
const sourceName = getFileName(this.moveSource.key);
const destKey = this.moveDestination + sourceName + (this.moveSource.isFolder ? '/' : '');
let result: { success: boolean; error?: string };
if (this.moveSource.isFolder) {
result = await apiService.movePrefix(
this.bucketName,
this.moveSource.key,
destKey
);
} else {
result = await apiService.moveObject(
this.bucketName,
this.moveSource.key,
destKey
);
}
if (result.success) {
this.closeMoveDialog();
await this.refreshAllColumns();
} else {
this.moveError = result.error || 'Move operation failed';
}
} catch (err) {
this.moveError = `Error: ${err}`;
}
this.moveInProgress = false;
}
private closeMoveDialog() {
this.showMoveDialog = false;
this.moveSource = null;
this.moveDestination = '';
this.moveError = null;
this.moveInProgress = false;
}
// --- Rename dialog methods ---
private openRenameDialog(key: string, isFolder: boolean) {
this.renameSource = { key, isFolder };
this.renameName = getFileName(key);
this.renameError = null;
this.showRenameDialog = true;
// Auto-focus and smart selection
this.updateComplete.then(() => {
const input = this.shadowRoot?.querySelector('.rename-dialog-input') as HTMLInputElement;
if (input) {
input.focus();
if (!isFolder) {
const lastDot = this.renameName.lastIndexOf('.');
if (lastDot > 0) {
input.setSelectionRange(0, lastDot);
} else {
input.select();
}
} else {
input.select();
}
}
});
}
private async executeRename() {
if (!this.renameSource || !this.renameName.trim()) return;
const newName = this.renameName.trim();
const currentName = getFileName(this.renameSource.key);
if (newName === currentName) {
this.renameError = 'Name is the same as current';
return;
}
if (!newName) {
this.renameError = 'Name cannot be empty';
return;
}
if (newName.includes('/')) {
this.renameError = 'Name cannot contain "/"';
return;
}
this.renameInProgress = true;
this.renameError = null;
try {
const parentPrefix = getParentPrefix(this.renameSource.key);
const newKey = parentPrefix + newName + (this.renameSource.isFolder ? '/' : '');
let result: { success: boolean; error?: string };
if (this.renameSource.isFolder) {
result = await apiService.movePrefix(this.bucketName, this.renameSource.key, newKey);
} else {
result = await apiService.moveObject(this.bucketName, this.renameSource.key, newKey);
}
if (result.success) {
this.closeRenameDialog();
await this.refreshAllColumns();
} else {
this.renameError = result.error || 'Rename failed';
}
} catch (err) {
this.renameError = `Error: ${err}`;
}
this.renameInProgress = false;
}
private closeRenameDialog() {
this.showRenameDialog = false;
this.renameSource = null;
this.renameName = '';
this.renameError = null;
this.renameInProgress = false;
}
private renderRenameDialog() {
if (!this.showRenameDialog || !this.renameSource) return '';
const isFolder = this.renameSource.isFolder;
const title = isFolder ? 'Rename Folder' : 'Rename File';
return html`
<div class="dialog-overlay" @click=${() => this.closeRenameDialog()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${getParentPrefix(this.renameSource.key)}
</div>
${this.renameError ? html`<div class="move-error">${this.renameError}</div>` : ''}
<input
type="text"
class="dialog-input rename-dialog-input"
placeholder=${isFolder ? 'folder-name' : 'filename.ext'}
.value=${this.renameName}
@input=${(e: InputEvent) => {
this.renameName = (e.target as HTMLInputElement).value;
this.renameError = null;
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter') this.executeRename();
if (e.key === 'Escape') this.closeRenameDialog();
}}
/>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel"
@click=${() => this.closeRenameDialog()}
?disabled=${this.renameInProgress}>Cancel</button>
<button class="dialog-btn dialog-btn-create"
@click=${() => this.executeRename()}
?disabled=${this.renameInProgress || !this.renameName.trim()}>
${this.renameInProgress ? 'Renaming...' : 'Rename'}
</button>
</div>
</div>
</div>
`;
}
private renderMoveDialog() {
if (!this.showMoveDialog || !this.moveSource) return '';
const sourceName = getFileName(this.moveSource.key);
return html`
<div class="dialog-overlay" @click=${() => this.closeMoveDialog()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">Move ${this.moveSource.isFolder ? 'Folder' : 'File'}</div>
${this.moveError ? html`
<div class="move-error">${this.moveError}</div>
` : html`
<div class="move-summary">
<div class="move-item">
<span class="move-label">From:</span>
<span class="move-path">${this.moveSource.key}</span>
</div>
<div class="move-item">
<span class="move-label">To:</span>
<span class="move-path">${this.moveDestination}${sourceName}</span>
</div>
</div>
`}
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel"
@click=${() => this.closeMoveDialog()}
?disabled=${this.moveInProgress}>
Cancel
</button>
${!this.moveError ? html`
<button class="dialog-btn dialog-btn-create"
@click=${() => this.executeMove()}
?disabled=${this.moveInProgress}>
${this.moveInProgress ? 'Moving...' : 'Move'}
</button>
` : ''}
</div>
</div>
</div>
`;
}
private renderMovePickerDialog() {
if (!this.showMovePickerDialog || !this.movePickerSource) return '';
const sourceName = getFileName(this.movePickerSource.key);
const sourceParent = getParentPrefix(this.movePickerSource.key);
return html`
<div class="dialog-overlay" @click=${() => this.closeMovePickerDialog()}>
<div class="dialog move-picker-dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">Move "${sourceName}" to...</div>
<div class="picker-breadcrumb">
<span class="picker-crumb" @click=${() => this.navigateMovePicker('')}>
${this.bucketName}
</span>
${this.getPathSegments(this.movePickerCurrentPrefix).map(seg => html`
<span class="picker-separator">/</span>
<span class="picker-crumb" @click=${() => this.navigateMovePicker(seg)}>
${getFileName(seg)}
</span>
`)}
</div>
<div class="picker-list">
${this.movePickerLoading ? html`<div class="picker-empty">Loading...</div>` : ''}
${!this.movePickerLoading && this.movePickerPrefixes.filter(p => p !== this.movePickerSource!.key).length === 0 ? html`
<div class="picker-empty">No subfolders</div>
` : ''}
${this.movePickerPrefixes
.filter(p => p !== this.movePickerSource!.key) // Hide source from list
.map(prefix => html`
<div class="picker-item"
@click=${() => this.navigateMovePicker(prefix)}
@dblclick=${() => this.selectMoveDestination(prefix)}>
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span class="name">${getFileName(prefix)}</span>
</div>
`)}
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.closeMovePickerDialog()}>
Cancel
</button>
<button class="dialog-btn dialog-btn-create"
@click=${() => this.selectMoveDestination(this.movePickerCurrentPrefix)}
?disabled=${this.movePickerCurrentPrefix === sourceParent}>
Move Here
</button>
</div>
</div>
</div>
`;
}
private async handleCreate() {
if (!this.createDialogName.trim()) return;
const name = this.createDialogName.trim();
let path: string;
if (this.createDialogType === 'folder') {
// Support deep paths: "a/b/c" creates nested folders
path = this.createDialogPrefix + name + '/.keep';
} else {
path = this.createDialogPrefix + name;
}
const ext = name.split('.').pop()?.toLowerCase() || '';
const contentType = this.createDialogType === 'file' ? this.getContentType(ext) : 'application/octet-stream';
const content = this.createDialogType === 'file' ? this.getDefaultContent(ext) : '';
const success = await apiService.putObject(
this.bucketName,
path,
btoa(content),
contentType
);
if (success) {
this.showCreateDialog = false;
await this.refreshColumnByPrefix(this.createDialogPrefix);
}
}
private renderCreateDialog() {
if (!this.showCreateDialog) return '';
const isFolder = this.createDialogType === 'folder';
const title = isFolder ? 'Create New Folder' : 'Create New File';
const placeholder = isFolder ? 'folder-name or path/to/folder' : 'filename.txt or path/to/file.txt';
return html`
<div class="dialog-overlay" @click=${() => this.showCreateDialog = false}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog-title">${title}</div>
<div class="dialog-location">
Location: ${this.bucketName}/${this.createDialogPrefix}
</div>
<input
type="text"
class="dialog-input"
placeholder=${placeholder}
.value=${this.createDialogName}
@input=${(e: InputEvent) => this.createDialogName = (e.target as HTMLInputElement).value}
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
/>
<div class="dialog-hint">
Use "/" to create nested ${isFolder ? 'folders' : 'path'} (e.g., ${isFolder ? 'parent/child' : 'folder/file.txt'})
</div>
<div class="dialog-actions">
<button class="dialog-btn dialog-btn-cancel" @click=${() => this.showCreateDialog = false}>
Cancel
</button>
<button
class="dialog-btn dialog-btn-create"
?disabled=${!this.createDialogName.trim()}
@click=${() => this.handleCreate()}
>
Create
</button>
</div>
</div>
</div>
`;
}
render() {
if (this.loading && this.columns.length === 0) {
return html`<div class="loading">Loading...</div>`;
}
return html`
<div class="columns-container">
${this.columns.map((column, index) => this.renderColumnWrapper(column, index))}
</div>
${this.renderCreateDialog()}
${this.renderMoveDialog()}
${this.renderMovePickerDialog()}
${this.renderRenameDialog()}
`;
}
private renderColumnWrapper(column: IColumn, index: number) {
return html`
<div class="column-wrapper">
${this.renderColumn(column, index)}
<div
class="resize-handle ${this.resizing?.columnIndex === index ? 'active' : ''}"
@mousedown=${(e: MouseEvent) => this.startResize(e, index)}
></div>
</div>
`;
}
private renderColumn(column: IColumn, index: number) {
const headerName = column.prefix
? getFileName(column.prefix)
: this.bucketName;
return html`
<div
class="column ${this.dragOverColumnIndex === index ? 'drag-over' : ''}"
style="width: ${column.width}px"
@dragenter=${(e: DragEvent) => this.handleColumnDragEnter(e, index)}
@dragover=${(e: DragEvent) => this.handleColumnDragOver(e)}
@dragleave=${(e: DragEvent) => this.handleColumnDragLeave(e, index)}
@drop=${(e: DragEvent) => this.handleColumnDrop(e, index)}
>
<div class="column-header" title=${column.prefix || this.bucketName}>
${headerName}
</div>
<div class="column-items" @contextmenu=${(e: MouseEvent) => this.handleEmptySpaceContextMenu(e, index)}>
${column.prefixes.length === 0 && column.objects.length === 0
? html`<div class="empty-state">Empty folder</div>`
: ''}
${column.prefixes.map(
(prefix) => html`
<div
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''} ${this.dragOverFolderPrefix === prefix ? 'drag-target' : ''}"
draggable="true"
@click=${() => this.selectFolder(index, prefix)}
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
@dragstart=${(e: DragEvent) => this.handleItemDragStart(e, prefix, true)}
@dragend=${(e: DragEvent) => this.handleItemDragEnd(e)}
@dragenter=${(e: DragEvent) => this.handleFolderDragEnter(e, prefix)}
@dragover=${(e: DragEvent) => this.handleFolderDragOver(e)}
@dragleave=${(e: DragEvent) => this.handleFolderDragLeave(e, prefix)}
>
<svg class="icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span class="name">${getFileName(prefix)}</span>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
`
)}
${column.objects.map(
(obj) => html`
<div
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
draggable="true"
@click=${() => this.selectFile(index, obj.key)}
@contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)}
@dragstart=${(e: DragEvent) => this.handleItemDragStart(e, obj.key, false)}
@dragend=${(e: DragEvent) => this.handleItemDragEnd(e)}
>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="${this.getFileIcon(obj.key)}" />
</svg>
<span class="name">${getFileName(obj.key)}</span>
</div>
`
)}
</div>
${this.dragOverColumnIndex === index ? html`
<div class="drag-hint">
${this.draggedItem
? (this.dragOverFolderPrefix
? `Move to ${getFileName(this.dragOverFolderPrefix)}`
: 'Move here')
: (this.dragOverFolderPrefix
? `Drop to upload into ${getFileName(this.dragOverFolderPrefix)}`
: 'Drop to upload here')}
</div>
` : ''}
${this.uploading ? html`
<div class="upload-overlay">
<div class="upload-text">Uploading...</div>
${this.uploadProgress ? html`
<div class="upload-progress">${this.uploadProgress.current} / ${this.uploadProgress.total} files</div>
` : ''}
</div>
` : ''}
</div>
`;
}
}