8 Commits

13 changed files with 493 additions and 85 deletions

View File

@@ -1,5 +1,32 @@
# Changelog # Changelog
## 2026-01-25 - 1.2.0 - feat(s3,web-ui)
add S3 deletePrefix and getObjectUrl endpoints and add context menus in UI for S3 and Mongo views
- Add server-side TypedHandlers: deletePrefix and getObjectUrl (ts/api/handlers.s3.ts)
- Add request/response interfaces IReq_DeletePrefix and IReq_GetObjectUrl (ts/interfaces/index.ts)
- Add client API methods deletePrefix and getObjectUrl (ts_web/services/api.service.ts)
- Introduce context menu actions (DeesContextmenu) across UI: bucket/database/collection/document/folder/file actions including open, copy path, delete, download and duplicate (ts_web/elements/tsview-app.ts, tsview-mongo-collections.ts, tsview-mongo-documents.ts, tsview-s3-columns.ts, tsview-s3-keys.ts)
- Switch from inline delete buttons to contextual menus for safer UX; implement downloads via data URLs returned by getObjectUrl and deletion of S3 prefixes (folders)
## 2026-01-25 - 1.1.3 - fix(package)
update package metadata
- metadata-only change; no source code changes
- current version 1.1.2 → recommended patch bump to 1.1.3
## 2026-01-25 - 1.1.2 - fix(package)
apply minor metadata-only change (one-line edit)
- Change affects 1 file with a +1 -1 (metadata-only) — no behavioral changes
- Recommended bump of patch version from 1.1.1 to 1.1.2
## 2026-01-25 - 1.1.1 - fix(tsview)
fix bad build commit - remove accidental include
- Removed an accidental include that caused a bad build and unintended files to be part of the commit
- Patch release recommended from 1.1.0 to 1.1.1
## 2026-01-25 - 1.1.0 - feat(tsview) ## 2026-01-25 - 1.1.0 - feat(tsview)
add database and S3 handlers, tswatch/watch scripts, web utilities, assets and release config add database and S3 handlers, tswatch/watch scripts, web utilities, assets and release config

View File

@@ -1,6 +1,6 @@
{ {
"name": "@git.zone/tsview", "name": "@git.zone/tsview",
"version": "1.1.0", "version": "1.2.0",
"private": false, "private": false,
"description": "A CLI tool for viewing S3 and MongoDB data with a web UI", "description": "A CLI tool for viewing S3 and MongoDB data with a web UI",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', name: '@git.zone/tsview',
version: '1.1.0', version: '1.2.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI' description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
} }

View File

@@ -364,4 +364,103 @@ export async function registerS3Handlers(
} }
) )
); );
// Delete prefix (folder and all contents)
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_DeletePrefix>(
'deletePrefix',
async (reqData) => {
const smartbucket = await tsview.getSmartBucket();
if (!smartbucket) {
return { success: false };
}
try {
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
if (!bucket) {
return { success: false };
}
const baseDir = await bucket.getBaseDirectory();
let targetDir = baseDir;
// Navigate to the prefix directory
const prefix = reqData.prefix.replace(/\/$/, '');
const prefixParts = prefix.split('/').filter(Boolean);
for (const part of prefixParts) {
const subDir = await targetDir.getSubDirectoryByName(part, { getEmptyDirectory: true });
if (subDir) {
targetDir = subDir;
} else {
return { success: false };
}
}
// Delete the directory and all its contents
await targetDir.delete({ mode: 'permanent' });
return { success: true };
} catch (err) {
console.error('Error deleting prefix:', err);
return { success: false };
}
}
)
);
// Get object URL (for downloads)
typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.IReq_GetObjectUrl>(
'getObjectUrl',
async (reqData) => {
const smartbucket = await tsview.getSmartBucket();
if (!smartbucket) {
throw new Error('S3 not configured');
}
try {
const bucket = await smartbucket.getBucketByName(reqData.bucketName);
if (!bucket) {
throw new Error(`Bucket ${reqData.bucketName} not found`);
}
// Get the content and create a data URL
const content = await bucket.fastGet({ path: reqData.key });
const ext = reqData.key.split('.').pop()?.toLowerCase() || '';
const contentTypeMap: Record<string, string> = {
'json': 'application/json',
'txt': 'text/plain',
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'ts': 'text/plain',
'tsx': 'text/plain',
'jsx': 'text/plain',
'md': 'text/markdown',
'csv': 'text/csv',
'yaml': 'text/yaml',
'yml': 'text/yaml',
'log': 'text/plain',
'sh': 'text/plain',
'env': 'text/plain',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
'pdf': 'application/pdf',
'xml': 'application/xml',
};
const contentType = contentTypeMap[ext] || 'application/octet-stream';
const base64 = content.toString('base64');
const url = `data:${contentType};base64,${base64}`;
return { url };
} catch (err) {
console.error('Error getting object URL:', err);
throw err;
}
}
)
);
} }

File diff suppressed because one or more lines are too long

View File

@@ -199,6 +199,34 @@ export interface IReq_CopyObject extends plugins.typedrequestInterfaces.implemen
}; };
} }
export interface IReq_DeletePrefix extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeletePrefix
> {
method: 'deletePrefix';
request: {
bucketName: string;
prefix: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetObjectUrl extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetObjectUrl
> {
method: 'getObjectUrl';
request: {
bucketName: string;
key: string;
};
response: {
url: string;
};
}
// =========================================== // ===========================================
// TypedRequest interfaces for MongoDB API // TypedRequest interfaces for MongoDB API
// =========================================== // ===========================================

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/tsview', name: '@git.zone/tsview',
version: '1.1.0', version: '1.2.0',
description: 'A CLI tool for viewing S3 and MongoDB data with a web UI' description: 'A CLI tool for viewing S3 and MongoDB data with a web UI'
} }

View File

@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, state, DeesElement } = plugins; const { html, css, cssManager, customElement, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
type TViewMode = 's3' | 'mongo' | 'settings'; type TViewMode = 's3' | 'mongo' | 'settings';
@@ -359,30 +360,6 @@ export class TsviewApp extends DeesElement {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.delete-btn {
opacity: 0;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.sidebar-item:hover .delete-btn,
.db-group-header:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
`, `,
]; ];
@@ -495,6 +472,66 @@ export class TsviewApp extends DeesElement {
} }
} }
private handleBucketContextMenu(event: MouseEvent, bucket: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'View Contents',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectBucket(bucket);
},
},
{ divider: true },
{
name: 'Delete Bucket',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete bucket "${bucket}"? This will delete all objects in the bucket.`)) {
const success = await apiService.deleteBucket(bucket);
if (success) {
this.buckets = this.buckets.filter(b => b !== bucket);
if (this.selectedBucket === bucket) {
this.selectedBucket = this.buckets[0] || '';
}
}
}
},
},
]);
}
private handleDatabaseContextMenu(event: MouseEvent, dbName: string) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'New Collection',
iconName: 'lucide:folderPlus',
action: async () => {
this.selectedDatabase = dbName;
this.showCreateCollectionDialog = true;
},
},
{ divider: true },
{
name: 'Delete Database',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete database "${dbName}"? This will delete all collections and documents.`)) {
const success = await apiService.dropDatabase(dbName);
if (success) {
this.databases = this.databases.filter(d => d.name !== dbName);
if (this.selectedDatabase === dbName) {
this.selectedDatabase = this.databases[0]?.name || '';
this.selectedCollection = '';
}
}
}
},
},
]);
}
render() { render() {
return html` return html`
<div class="app-container"> <div class="app-container">
@@ -653,14 +690,9 @@ export class TsviewApp extends DeesElement {
<div <div
class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}" class="sidebar-item ${bucket === this.selectedBucket ? 'selected' : ''}"
@click=${() => this.selectBucket(bucket)} @click=${() => this.selectBucket(bucket)}
@contextmenu=${(e: MouseEvent) => this.handleBucketContextMenu(e, bucket)}
> >
<span class="sidebar-item-name">${bucket}</span> <span class="sidebar-item-name">${bucket}</span>
<button class="delete-btn" @click=${(e: Event) => this.deleteBucket(bucket, e)} title="Delete bucket">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div> </div>
` `
)} )}
@@ -680,15 +712,6 @@ export class TsviewApp extends DeesElement {
</svg> </svg>
New Database New Database
</button> </button>
${this.selectedDatabase ? html`
<button class="create-btn" @click=${() => this.showCreateCollectionDialog = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
New Collection
</button>
` : ''}
<div class="sidebar-list"> <div class="sidebar-list">
${this.databases.length === 0 ${this.databases.length === 0
? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>` ? html`<div class="sidebar-item" style="color: #666; cursor: default;">No databases found</div>`
@@ -715,6 +738,7 @@ export class TsviewApp extends DeesElement {
<div <div
class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}" class="db-group-header ${this.selectedDatabase === db.name ? 'selected' : ''}"
@click=${() => this.selectDatabase(db.name)} @click=${() => this.selectDatabase(db.name)}
@contextmenu=${(e: MouseEvent) => this.handleDatabaseContextMenu(e, db.name)}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse> <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
@@ -722,12 +746,6 @@ export class TsviewApp extends DeesElement {
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path> <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg> </svg>
<span style="flex: 1;">${db.name}</span> <span style="flex: 1;">${db.name}</span>
<button class="delete-btn" @click=${(e: Event) => this.deleteDatabase(db.name, e)} title="Delete database">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div> </div>
${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''} ${this.selectedDatabase === db.name ? this.renderCollectionsList(db.name) : ''}
</div> </div>

View File

@@ -4,6 +4,7 @@ import { formatCount } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
declare global { declare global {
interface HTMLElementEventMap { interface HTMLElementEventMap {
@@ -89,29 +90,6 @@ export class TsviewMongoCollections extends DeesElement {
font-size: 12px; font-size: 12px;
font-style: italic; font-style: italic;
} }
.delete-btn {
opacity: 0;
padding: 4px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.collection-item:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
`, `,
]; ];
@@ -149,8 +127,7 @@ export class TsviewMongoCollections extends DeesElement {
); );
} }
private async deleteCollection(name: string, e: Event) { private async deleteCollection(name: string) {
e.stopPropagation();
if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return; if (!confirm(`Delete collection "${name}"? This will delete all documents.`)) return;
const success = await apiService.dropCollection(this.databaseName, name); const success = await apiService.dropCollection(this.databaseName, name);
@@ -166,6 +143,27 @@ export class TsviewMongoCollections extends DeesElement {
} }
} }
private handleCollectionContextMenu(event: MouseEvent, collection: IMongoCollection) {
event.preventDefault();
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'View Documents',
iconName: 'lucide:fileText',
action: async () => {
this.selectCollection(collection.name);
},
},
{ divider: true },
{
name: 'Delete Collection',
iconName: 'lucide:trash2',
action: async () => {
await this.deleteCollection(collection.name);
},
},
]);
}
public async refresh() { public async refresh() {
await this.loadCollections(); await this.loadCollections();
} }
@@ -186,6 +184,7 @@ export class TsviewMongoCollections extends DeesElement {
<div <div
class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}" class="collection-item ${this.selectedCollection === coll.name ? 'selected' : ''}"
@click=${() => this.selectCollection(coll.name)} @click=${() => this.selectCollection(coll.name)}
@contextmenu=${(e: MouseEvent) => this.handleCollectionContextMenu(e, coll)}
> >
<span class="collection-name"> <span class="collection-name">
<svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="collection-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -193,17 +192,9 @@ export class TsviewMongoCollections extends DeesElement {
</svg> </svg>
${coll.name} ${coll.name}
</span> </span>
<span style="display: flex; align-items: center; gap: 4px;"> ${coll.count !== undefined
${coll.count !== undefined ? html`<span class="collection-count">${formatCount(coll.count)}</span>`
? html`<span class="collection-count">${formatCount(coll.count)}</span>` : ''}
: ''}
<button class="delete-btn" @click=${(e: Event) => this.deleteCollection(coll.name, e)} title="Delete collection">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</span>
</div> </div>
` `
)} )}

View File

@@ -3,6 +3,7 @@ import { apiService } from '../services/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
@customElement('tsview-mongo-documents') @customElement('tsview-mongo-documents')
export class TsviewMongoDocuments extends DeesElement { export class TsviewMongoDocuments extends DeesElement {
@@ -330,6 +331,74 @@ export class TsviewMongoDocuments extends DeesElement {
} }
} }
private handleDocumentContextMenu(event: MouseEvent, doc: Record<string, unknown>) {
event.preventDefault();
const docId = doc._id as string;
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'View/Edit',
iconName: 'lucide:edit',
action: async () => {
this.selectDocument(doc);
},
},
{
name: 'Copy as JSON',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(JSON.stringify(doc, null, 2));
},
},
{
name: 'Duplicate',
iconName: 'lucide:copyPlus',
action: async () => {
const { _id, ...docWithoutId } = doc;
const newDoc = { ...docWithoutId, createdAt: new Date().toISOString() };
try {
const insertedId = await apiService.insertDocument(
this.databaseName,
this.collectionName,
newDoc
);
await this.loadDocuments();
this.selectedId = insertedId;
this.dispatchEvent(
new CustomEvent('document-selected', {
detail: { documentId: insertedId },
bubbles: true,
composed: true,
})
);
} catch (err) {
console.error('Error duplicating document:', err);
}
},
},
{ divider: true },
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete document "${docId}"?`)) {
const result = await apiService.deleteDocument(
this.databaseName,
this.collectionName,
docId
);
if (result.success) {
await this.loadDocuments();
if (this.selectedId === docId) {
this.selectedId = '';
}
}
}
},
},
]);
}
render() { render() {
const startRecord = (this.page - 1) * this.pageSize + 1; const startRecord = (this.page - 1) * this.pageSize + 1;
const endRecord = Math.min(this.page * this.pageSize, this.total); const endRecord = Math.min(this.page * this.pageSize, this.total);
@@ -362,6 +431,7 @@ export class TsviewMongoDocuments extends DeesElement {
<div <div
class="document-row ${this.selectedId === doc._id ? 'selected' : ''}" class="document-row ${this.selectedId === doc._id ? 'selected' : ''}"
@click=${() => this.selectDocument(doc)} @click=${() => this.selectDocument(doc)}
@contextmenu=${(e: MouseEvent) => this.handleDocumentContextMenu(e, doc)}
> >
<div class="document-id">_id: ${doc._id}</div> <div class="document-id">_id: ${doc._id}</div>
<div class="document-preview">${this.getDocumentPreview(doc)}</div> <div class="document-preview">${this.getDocumentPreview(doc)}</div>

View File

@@ -4,6 +4,7 @@ import { getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
interface IColumn { interface IColumn {
prefix: string; prefix: string;
@@ -318,6 +319,83 @@ export class TsviewS3Columns extends DeesElement {
return iconMap[ext] || '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);
},
},
{ 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) {
await this.loadInitialColumn();
}
}
},
},
]);
}
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);
},
},
{ 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.loadInitialColumn();
}
}
},
},
]);
}
render() { render() {
if (this.loading && this.columns.length === 0) { if (this.loading && this.columns.length === 0) {
return html`<div class="loading">Loading...</div>`; return html`<div class="loading">Loading...</div>`;
@@ -361,6 +439,7 @@ export class TsviewS3Columns extends DeesElement {
<div <div
class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}" class="column-item folder ${column.selectedItem === prefix ? 'selected' : ''}"
@click=${() => this.selectFolder(index, prefix)} @click=${() => this.selectFolder(index, prefix)}
@contextmenu=${(e: MouseEvent) => this.handleFolderContextMenu(e, index, prefix)}
> >
<svg class="icon" viewBox="0 0 24 24" fill="currentColor"> <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" /> <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" />
@@ -377,6 +456,7 @@ export class TsviewS3Columns extends DeesElement {
<div <div
class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}" class="column-item ${column.selectedItem === obj.key ? 'selected' : ''}"
@click=${() => this.selectFile(index, obj.key)} @click=${() => this.selectFile(index, obj.key)}
@contextmenu=${(e: MouseEvent) => this.handleFileContextMenu(e, index, obj.key)}
> >
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="${this.getFileIcon(obj.key)}" /> <path d="${this.getFileIcon(obj.key)}" />

View File

@@ -4,6 +4,7 @@ import { formatSize, getFileName } from '../utilities/index.js';
import { themeStyles } from '../styles/index.js'; import { themeStyles } from '../styles/index.js';
const { html, css, cssManager, customElement, property, state, DeesElement } = plugins; const { html, css, cssManager, customElement, property, state, DeesElement } = plugins;
const { DeesContextmenu } = plugins.deesCatalog;
@customElement('tsview-s3-keys') @customElement('tsview-s3-keys')
export class TsviewS3Keys extends DeesElement { export class TsviewS3Keys extends DeesElement {
@@ -210,6 +211,83 @@ export class TsviewS3Keys extends DeesElement {
return [...folders, ...files]; return [...folders, ...files];
} }
private handleItemContextMenu(event: MouseEvent, key: string, isFolder: boolean) {
event.preventDefault();
if (isFolder) {
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Open',
iconName: 'lucide:folderOpen',
action: async () => {
this.selectKey(key, true);
},
},
{
name: 'Copy Path',
iconName: 'lucide:copy',
action: async () => {
await navigator.clipboard.writeText(key);
},
},
{ divider: true },
{
name: 'Delete Folder',
iconName: 'lucide:trash2',
action: async () => {
if (confirm(`Delete folder "${getFileName(key)}" and all its contents?`)) {
const success = await apiService.deletePrefix(this.bucketName, key);
if (success) {
await this.loadObjects();
}
}
},
},
]);
} else {
DeesContextmenu.openContextMenuWithOptions(event, [
{
name: 'Preview',
iconName: 'lucide:eye',
action: async () => {
this.selectKey(key, false);
},
},
{
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);
},
},
{ 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.loadObjects();
}
}
},
},
]);
}
}
render() { render() {
return html` return html`
<div class="keys-container"> <div class="keys-container">
@@ -242,6 +320,7 @@ export class TsviewS3Keys extends DeesElement {
<tr <tr
class="${this.selectedKey === item.key ? 'selected' : ''}" class="${this.selectedKey === item.key ? 'selected' : ''}"
@click=${() => this.selectKey(item.key, item.isFolder)} @click=${() => this.selectKey(item.key, item.isFolder)}
@contextmenu=${(e: MouseEvent) => this.handleItemContextMenu(e, item.key, item.isFolder)}
> >
<td> <td>
<div class="key-cell"> <div class="key-cell">

View File

@@ -128,6 +128,22 @@ export class ApiService {
return result.success; return result.success;
} }
async deletePrefix(bucketName: string, prefix: string): Promise<boolean> {
const result = await this.request<
{ bucketName: string; prefix: string },
{ success: boolean }
>('deletePrefix', { bucketName, prefix });
return result.success;
}
async getObjectUrl(bucketName: string, key: string): Promise<string> {
const result = await this.request<
{ bucketName: string; key: string },
{ url: string }
>('getObjectUrl', { bucketName, key });
return result.url;
}
async copyObject( async copyObject(
sourceBucket: string, sourceBucket: string,
sourceKey: string, sourceKey: string,