feat(ui): 重设计文件管理器书签与传输面板

新增书签作用域与连接关联,后端为 favorite_paths
补充 scope 和 connection_id 字段及查询写入支持

前端重构书签弹窗与编辑表单,支持本地/云端筛选、
作用域选择与多语言文案更新

文件管理器工具栏改为紧凑图标样式,上传入口合并为
下拉菜单,并新增底部传输面板统一展示上传任务

同时优化 SSH 终端运行态为显式状态机,并为短命令
补充最短可见时间,避免运行中标记闪烁难以感知
This commit is contained in:
yinjianm
2026-05-01 22:54:29 +08:00
parent 96d9950c6b
commit 2233e3fa4f
33 changed files with 1868 additions and 1541 deletions
@@ -23,24 +23,25 @@ const form = ref({
id: '',
path: '',
name: '',
scope: 'global' as 'global' | 'local',
});
const isEditMode = computed(() => !!props.pathData?.id);
const isLoading = ref(false);
const errorMessage = ref<string | null>(null);
watch(() => props.isVisible, (newValue) => {
if (newValue) {
errorMessage.value = null; // Reset error on open
errorMessage.value = null;
if (props.pathData) {
form.value = {
id: props.pathData.id,
path: props.pathData.path,
name: props.pathData.name || ''
form.value = {
id: props.pathData.id,
path: props.pathData.path,
name: props.pathData.name || '',
scope: (props.pathData.scope as 'global' | 'local') || 'global',
};
} else {
form.value = { id: '', path: '', name: '' };
form.value = { id: '', path: '', name: '', scope: 'global' };
}
}
}, { immediate: true });
@@ -50,7 +51,6 @@ const validateForm = (): boolean => {
errorMessage.value = t('favoritePaths.addEditForm.validation.pathRequired', 'Path is required.');
return false;
}
// Add other validation rules if needed
errorMessage.value = null;
return true;
};
@@ -65,12 +65,14 @@ const handleSubmit = async () => {
if (isEditMode.value && form.value.id) {
await favoritePathsStore.updateFavoritePath(form.value.id, {
path: form.value.path,
name: form.value.name || undefined, // Send undefined if empty to allow backend to handle
name: form.value.name || undefined,
scope: form.value.scope,
}, t);
} else {
await favoritePathsStore.addFavoritePath({
path: form.value.path,
name: form.value.name || undefined,
scope: form.value.scope,
}, t);
}
emit('saveSuccess');
@@ -78,37 +80,35 @@ const handleSubmit = async () => {
} catch (error: any) {
console.error('Error saving favorite path:', error);
errorMessage.value = error.message || t('favoritePaths.addEditForm.errors.genericSaveError', 'Failed to save favorite path.');
// Notification is usually handled by the store, but we can show a local error too.
} finally {
isLoading.value = false;
}
};
const closeModal = () => {
if (!isLoading.value) { // Prevent closing while loading
if (!isLoading.value) {
emit('close');
}
};
</script>
<template>
<div
v-if="isVisible"
<div
v-if="isVisible"
class="fixed inset-0 z-[60] flex items-center justify-center bg-[var(--overlay-bg-color)]"
@click.self="closeModal"
>
<div class="bg-background text-foreground shadow-xl rounded-lg w-full max-w-md flex flex-col overflow-hidden m-4 p-6">
<!-- Header -->
<h2 class="m-0 mb-6 text-center text-xl font-semibold">
{{ isEditMode ? t('favoritePaths.addEditForm.editTitle', 'Edit Favorite Path') : t('favoritePaths.addEditForm.addTitle', 'Add New Favorite Path') }}
{{ isEditMode ? t('favoritePaths.addEditForm.editTitle', '编辑书签') : t('favoritePaths.addEditForm.addTitle', '添加书签') }}
</h2>
<!-- Form Body -->
<form @submit.prevent="handleSubmit" class="space-y-4 flex-grow overflow-y-auto">
<div>
<label for="favPath-name" class="block text-sm font-medium text-text-secondary mb-1">
{{ t('favoritePaths.addEditForm.nameLabel', 'Name (Optional)') }}
{{ t('favoritePaths.addEditForm.nameLabel', '名称(可选)') }}
</label>
<input
id="favPath-name"
@@ -121,7 +121,7 @@ const closeModal = () => {
</div>
<div>
<label for="favPath-path" class="block text-sm font-medium text-text-secondary mb-1">
{{ t('favoritePaths.addEditForm.pathLabel', 'Path') }}
{{ t('favoritePaths.addEditForm.pathLabel', '路径') }}
<span class="text-danger ml-0.5">*</span>
</label>
<input
@@ -135,6 +135,41 @@ const closeModal = () => {
/>
</div>
<!-- Scope Selector -->
<div>
<label class="block text-sm font-medium text-text-secondary mb-1.5">
{{ t('favoritePaths.addEditForm.scopeLabel', '记录位置') }}
</label>
<div class="flex gap-2">
<button
type="button"
@click="form.scope = 'local'"
:class="[
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm border transition-colors',
form.scope === 'local'
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
: 'border-border bg-input text-text-secondary hover:border-border hover:bg-white/5'
]"
>
<i class="fas fa-server text-xs"></i>
{{ t('favoritePaths.scopeLocalLabel', '仅当前服务器') }}
</button>
<button
type="button"
@click="form.scope = 'global'"
:class="[
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm border transition-colors',
form.scope === 'global'
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-400'
: 'border-border bg-input text-text-secondary hover:border-border hover:bg-white/5'
]"
>
<i class="fas fa-cloud text-xs"></i>
{{ t('favoritePaths.scopeGlobalLabel', '全局共享') }}
</button>
</div>
</div>
<div v-if="errorMessage" class="text-danger text-sm p-2 bg-danger/10 rounded-md">
{{ errorMessage }}
</div>
@@ -142,30 +177,21 @@ const closeModal = () => {
<!-- Footer -->
<div class="flex justify-end mt-8 pt-4 border-t border-border/50">
<!-- Secondary/Cancel Button -->
<button
type="button"
@click="closeModal"
:disabled="isLoading"
class="py-2 px-5 rounded-lg text-sm font-medium transition-colors duration-150 bg-background border border-border/50 text-text-secondary hover:bg-border hover:text-foreground mr-3">
{{ t('common.cancel', 'Cancel') }}
{{ t('common.cancel', '取消') }}
</button>
<!-- Primary/Submit Button -->
<button
type="submit"
@click="handleSubmit"
:disabled="isLoading || !form.path.trim()"
class="py-2 px-5 rounded-lg text-sm font-semibold transition-colors duration-150 bg-primary text-white border-none shadow-md hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:opacity-70 disabled:cursor-not-allowed">
{{ isLoading ? t('common.saving', 'Saving...') : t('common.save', 'Save') }}
{{ isLoading ? t('common.saving', '保存中...') : t('common.save', '保存') }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* Styles are primarily Tailwind based */
.bg-background-hover:hover {
background-color: var(--color-bg-hover); /* Ensure this CSS variable is defined globally or in Tailwind config */
}
</style>
</template>
@@ -7,7 +7,7 @@ import AddEditFavoritePathForm from './AddEditFavoritePathForm.vue';
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents';
import { useConfirmDialog } from '../composables/useConfirmDialog';
const PADDING = 8; // px
const PADDING = 8;
const props = defineProps({
isVisible: {
@@ -34,28 +34,27 @@ const editingPathItem = ref<FavoritePathItem | null>(null);
const modalContentRef = ref<HTMLElement | null>(null);
const modalStyle = ref<Record<string, string>>({});
const scopeTabs = computed(() => [
{ key: 'all' as const, label: t('favoritePaths.scopeAll', '全部') },
{ key: 'local' as const, label: t('favoritePaths.scopeLocal', '本地') },
{ key: 'global' as const, label: t('favoritePaths.scopeGlobal', '云端') },
]);
const filteredPaths = computed(() => {
if (!searchTerm.value) {
return favoritePathsStore.favoritePaths;
}
const lowerSearchTerm = searchTerm.value.toLowerCase();
return favoritePathsStore.favoritePaths.filter(
(p) =>
p.path.toLowerCase().includes(lowerSearchTerm) ||
(p.name && p.name.toLowerCase().includes(lowerSearchTerm))
);
return favoritePathsStore.filteredFavoritePaths.filter(p => {
if (!searchTerm.value) return true;
const lowerSearchTerm = searchTerm.value.toLowerCase();
return p.path.toLowerCase().includes(lowerSearchTerm) ||
(p.name && p.name.toLowerCase().includes(lowerSearchTerm));
});
});
// Computed property for sort button icon and title
const currentSortBy = computed(() => favoritePathsStore.currentSortBy);
const sortButtonIcon = computed(() => {
return currentSortBy.value === 'name' ? 'fas fa-sort-alpha-down' : 'fas fa-clock';
});
const toggleSort = () => {
const newSortBy = currentSortBy.value === 'name' ? 'last_used_at' : 'name';
favoritePathsStore.setSortBy(newSortBy);
@@ -63,11 +62,9 @@ const toggleSort = () => {
const handleItemClick = async (pathItem: FavoritePathItem) => {
try {
// Mark path as used before navigating
await favoritePathsStore.markPathAsUsed(pathItem.id, t);
} catch (error) {
console.error('Failed to mark path as used:', error);
// Optionally, inform the user about the failure, though navigation will still proceed.
}
emit('navigateToPath', pathItem.path);
closeModal();
@@ -109,7 +106,7 @@ const handleSendToTerminal = (pathItem: FavoritePathItem) => {
} else {
console.warn('[FavoritePathsModal] No active session with a terminal manager found to send path to.');
}
closeModal();
closeModal();
};
const closeModal = () => {
@@ -125,58 +122,47 @@ const updatePosition = () => {
const modalWidth = modalContentRef.value.offsetWidth;
const modalHeight = modalContentRef.value.offsetHeight;
// If dimensions are zero when modal is supposed to be visible,
// it might mean content affecting size isn't ready. Retry once.
if (modalWidth === 0 && modalHeight === 0 && props.isVisible) {
nextTick(updatePosition);
return;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let top = triggerRect.bottom + 2; // Default position below trigger, with a small 2px gap
let top = triggerRect.bottom + 2;
let left = triggerRect.left;
// Check for bottom overflow
if (top + modalHeight + PADDING > viewportHeight) {
// Try to position above the trigger
top = triggerRect.top - modalHeight - 2; // Position above trigger, with a small 2px gap
top = triggerRect.top - modalHeight - 2;
}
// If positioning above also causes top overflow (e.g., trigger is near the top and modal is tall)
if (top < PADDING) {
top = PADDING; // Align to viewport top with padding
// Note: If modalHeight is still greater than viewportHeight - 2*PADDING,
// it will overflow downwards. The `max-h-80` class on the modal
// should generally prevent the modal itself from being excessively tall.
top = PADDING;
}
// Check for right overflow
if (left + modalWidth + PADDING > viewportWidth) {
left = viewportWidth - modalWidth - PADDING; // Align to viewport right edge
left = viewportWidth - modalWidth - PADDING;
}
// Check for left overflow (less likely with initial left alignment to trigger, but good for robustness)
if (left < PADDING) {
left = PADDING; // Align to viewport left edge
left = PADDING;
}
modalStyle.value = {
position: 'fixed', // Position relative to the viewport
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
};
};
// --- Click Outside Logic ---
const handleClickOutside = (event: MouseEvent) => {
if (props.triggerElement && props.triggerElement.contains(event.target as Node)) {
return;
}
if (modalContentRef.value && !modalContentRef.value.contains(event.target as Node)) {
if (!showAddEditModal.value) {
if (!showAddEditModal.value) {
closeModal();
}
}
@@ -186,21 +172,21 @@ watch(() => props.isVisible, (newValue: boolean) => {
if (newValue) {
searchTerm.value = '';
document.addEventListener('mousedown', handleClickOutside);
nextTick(() => { // Ensure DOM is ready for measurements
updatePosition(); // Calculate initial position
window.addEventListener('resize', updatePosition); // Adjust position on window resize
nextTick(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
});
} else {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePosition); // Clean up resize listener
window.removeEventListener('resize', updatePosition);
}
});
onMounted(() => {
if (props.isVisible) {
searchTerm.value = '';
searchTerm.value = '';
document.addEventListener('mousedown', handleClickOutside);
nextTick(() => {
nextTick(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
});
@@ -209,94 +195,119 @@ onMounted(() => {
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePosition); // Ensure resize listener is cleaned up
window.removeEventListener('resize', updatePosition);
});
</script>
<template>
<!-- New single root element -->
<div>
<!-- Favorite Paths Dropdown -->
<div
v-if="isVisible"
ref="modalContentRef"
:style="modalStyle"
class="z-50 w-72 md:w-80 rounded-md bg-background shadow-lg border border-border/50 max-h-80 flex flex-col overflow-hidden"
>
<!-- Toolbar: Search and Add Button -->
<div class="p-2 flex-shrink-0 flex items-center gap-2">
<div class="relative flex-grow">
<input
type="text"
v-model="searchTerm"
:placeholder="t('favoritePaths.searchPlaceholder', 'Search by name or path...')"
class="w-full bg-input border border-border rounded-md pl-2.5 pr-2 py-1.5 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
/>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-border/40 flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-foreground">{{ t('favoritePaths.title', '书签列表') }}</span>
<span class="text-[10px] bg-primary/20 text-primary px-1.5 rounded-full">{{ favoritePathsStore.favoritePaths.length }}</span>
</div>
<div class="flex items-center gap-1">
<button
@click="toggleSort"
class="flex items-center justify-center w-6 h-6 text-text-secondary rounded hover:text-primary hover:bg-white/5 transition-colors"
:title="currentSortBy === 'name' ? t('favoritePaths.sortByUsage', '按使用排序') : t('favoritePaths.sortByName', '按名称排序')"
>
<i :class="sortButtonIcon" class="text-[11px]"></i>
</button>
<button
@click="openAddModal"
class="flex items-center justify-center w-6 h-6 text-primary rounded hover:bg-primary/10 transition-colors"
:title="t('favoritePaths.addNew', '添加书签')"
>
<i class="fas fa-plus text-[11px]"></i>
</button>
</div>
</div>
<!-- Scope Tabs -->
<div class="flex items-center gap-0.5 px-2 py-1 border-b border-border/30 flex-shrink-0">
<button
@click="toggleSort"
class="flex items-center justify-center w-8 h-8 bg-background border border-border text-text-secondary rounded-lg text-sm cursor-pointer shadow-sm transition-colors duration-200 ease-in-out hover:bg-primary/10 hover:text-primary focus:outline-none flex-shrink-0"
v-for="tab in scopeTabs"
:key="tab.key"
@click="favoritePathsStore.setActiveScope(tab.key)"
:class="[
'px-2 py-0.5 text-[11px] rounded transition-colors',
favoritePathsStore.activeScope === tab.key
? 'bg-primary/15 text-primary font-medium'
: 'text-text-secondary hover:text-foreground hover:bg-white/5'
]"
>
<i :class="sortButtonIcon"></i>
</button>
<button
@click="openAddModal"
class="flex items-center justify-center w-8 h-8 bg-primary text-white border-none rounded-lg text-sm font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary flex-shrink-0"
:title="t('favoritePaths.addNew', 'Add new favorite path')"
>
<i class="fas fa-plus text-base"></i>
{{ tab.label }}
</button>
</div>
<!-- Search -->
<div class="px-2 py-1.5 flex-shrink-0">
<input
type="text"
v-model="searchTerm"
:placeholder="t('favoritePaths.searchPlaceholder', '搜索书签...')"
class="w-full bg-input border border-border rounded px-2 py-1 text-xs outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
/>
</div>
<!-- Path List -->
<div class="overflow-y-auto flex-grow p-1 text-sm">
<div class="overflow-y-auto flex-grow px-1 pb-1 text-xs">
<div v-if="favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
<i class="fas fa-spinner fa-spin mr-1"></i>
{{ t('favoritePaths.loading', 'Loading favorites...') }}
</div>
<div v-else-if="!favoritePathsStore.isLoading && filteredPaths.length === 0" class="p-3 text-center text-text-secondary">
<i class="fas fa-star-half-alt mr-1"></i>
{{ searchTerm ? t('favoritePaths.noResults', 'No matching favorites found.') : t('favoritePaths.noFavorites', 'No favorite paths yet. Add one!') }}
<div v-else-if="!favoritePathsStore.isLoading && filteredPaths.length === 0" class="py-6 text-center text-text-secondary">
<i class="fas fa-bookmark text-lg mb-1 block opacity-40"></i>
{{ searchTerm ? t('favoritePaths.noResults', '未找到匹配的书签') : t('favoritePaths.noFavorites', '暂无书签') }}
</div>
<ul v-else-if="filteredPaths.length > 0" class="list-none m-0 p-0">
<li
<div v-else class="space-y-0.5">
<div
v-for="favPath in filteredPaths"
:key="favPath.id"
class="p-2 hover:bg-primary/10 cursor-pointer group flex items-center justify-between rounded-md transition-colors duration-150"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 cursor-pointer group transition-colors"
@click="handleItemClick(favPath)"
:title="favPath.path"
>
<div class="flex-grow overflow-hidden mr-2">
<p class="font-medium truncate text-foreground">
{{ favPath.name || favPath.path }}
</p>
<p v-if="favPath.name" class="text-xs text-text-secondary truncate">
{{ favPath.path }}
</p>
<i class="fas fa-bookmark text-[10px] text-primary/60 flex-shrink-0"></i>
<div class="flex-1 overflow-hidden min-w-0">
<div class="flex items-center gap-1.5">
<span class="truncate text-foreground text-[13px]">{{ favPath.name || favPath.path }}</span>
<span v-if="favPath.scope === 'local'" class="text-[9px] px-1 rounded bg-blue-500/15 text-blue-400 flex-shrink-0">{{ t('favoritePaths.scopeLocal', '本地') }}</span>
<span v-else class="text-[9px] px-1 rounded bg-emerald-500/15 text-emerald-400 flex-shrink-0">{{ t('favoritePaths.scopeGlobal', '云端') }}</span>
</div>
<p v-if="favPath.name" class="text-[11px] text-text-secondary/70 truncate mt-0.5">{{ favPath.path }}</p>
</div>
<div class="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150">
<div class="flex-shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click.stop="handleSendToTerminal(favPath)"
class="p-1.5 rounded text-text-secondary hover:text-primary hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
:title="t('favoritePaths.sendToTerminal', 'Send to Terminal')">
<i class="fas fa-terminal text-xs"></i>
class="p-1 rounded text-text-secondary hover:text-primary hover:bg-white/10 transition-colors"
:title="t('favoritePaths.sendToTerminal', '发送到终端')">
<i class="fas fa-terminal text-[10px]"></i>
</button>
<button
@click.stop="openEditModal(favPath)"
class="p-1.5 rounded text-text-secondary hover:text-primary hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
class="p-1 rounded text-text-secondary hover:text-primary hover:bg-white/10 transition-colors"
:title="t('common.edit')">
<i class="fas fa-pencil-alt text-xs"></i>
<i class="fas fa-pencil-alt text-[10px]"></i>
</button>
<button
@click.stop="handleDelete(favPath)"
class="p-1.5 rounded text-text-secondary hover:text-error hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
class="p-1 rounded text-text-secondary hover:text-red-400 hover:bg-white/10 transition-colors"
:title="t('common.delete')">
<i class="fas fa-trash-alt text-xs"></i>
<i class="fas fa-trash-alt text-[10px]"></i>
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -308,7 +319,5 @@ onBeforeUnmount(() => {
@close="showAddEditModal = false"
@save-success="() => { favoritePathsStore.fetchFavoritePaths(t); showAddEditModal = false; }"
/>
</div>
</template>
</div>
</template>
@@ -15,6 +15,7 @@ import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileMa
import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation';
import { createFolderArchive, type FolderArchiveSource } from '../composables/file-manager/useFolderArchiveUpload';
import FileUploadPopup from './FileUploadPopup.vue';
import TransferPanel from './TransferPanel.vue';
import FileManagerContextMenu from './FileManagerContextMenu.vue';
import FileManagerActionModal from './FileManagerActionModal.vue';
import type { FileListItem } from '../types/sftp.types';
@@ -150,6 +151,8 @@ const editablePath = ref('');
const fileListContainerRef = ref<HTMLDivElement | null>(null); // 文件列表容器引用
const dropOverlayRef = ref<HTMLDivElement | null>(null); // +++ 拖拽蒙版引用 +++
const isFolderUploadBusy = ref(false);
const uploadMenuOpen = ref(false);
const showTransferPanel = ref(false);
// +++ Favorite Paths Modal State +++
const showFavoritePathsModal = ref(false);
@@ -1741,6 +1744,7 @@ onMounted(() => {
};
unregisterPathFocusAction = focusSwitcherStore.registerFocusAction('fileManagerPathInput', focusPathActionWrapper);
document.addEventListener('click', handleClickOutsidePathInput);
document.addEventListener('click', handleClickOutsideUploadMenu);
});
onBeforeUnmount(() => {
@@ -1758,6 +1762,7 @@ onBeforeUnmount(() => {
}
unregisterPathFocusAction = null;
document.removeEventListener('click', handleClickOutsidePathInput);
document.removeEventListener('click', handleClickOutsideUploadMenu);
sessionStore.removeSftpManager(props.sessionId, props.instanceId);
});
@@ -2000,6 +2005,12 @@ const handleClickOutsidePathInput = (event: MouseEvent) => {
}
};
const handleClickOutsideUploadMenu = (event: MouseEvent) => {
if (uploadMenuOpen.value) {
uploadMenuOpen.value = false;
}
};
// --- 搜索框激活/取消逻辑 ---
const activateSearch = () => {
@@ -2368,7 +2379,7 @@ watch(
</div>
</div> <!-- End Wrapper -->
<!-- Main Actions Bar -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="flex items-center gap-0.5 flex-shrink-0">
<input type="file" ref="fileInputRef" @change="handleFileSelected" multiple class="hidden" />
<input type="file" ref="folderInputRef" @change="handleFolderSelected" webkitdirectory directory multiple class="hidden" />
<!-- 打开编辑器按钮 -->
@@ -2377,62 +2388,63 @@ watch(
@click="openPopupEditor"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.openEditor', 'Open Popup Editor')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="far fa-edit text-sm"></i> <!-- 使用编辑图标 -->
<span v-if="!props.isMobile">{{ t('fileManager.actions.openEditor', 'Open Editor') }}</span> <!-- 添加 i18n key -->
</button>
<!-- 上传按钮 -->
<button
@click="triggerFileUpload"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.uploadFile')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i class="fas fa-upload text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFile') }}</span>
</button>
<button
@click="triggerFolderUpload"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value || isFolderUploadBusy"
:title="t('fileManager.actions.uploadFolder')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
>
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin text-sm' : 'fas fa-folder-open text-sm'"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.uploadFolder') }}</span>
<i class="far fa-edit text-sm"></i>
</button>
<!-- 上传下拉菜单 -->
<div class="relative">
<button
@click="uploadMenuOpen = !uploadMenuOpen"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.uploadFile')"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="fas fa-upload text-sm"></i>
</button>
<div v-if="uploadMenuOpen" class="absolute right-0 top-full mt-1 bg-background border border-border rounded-md shadow-lg z-50 py-1 min-w-[140px]">
<button
@click="triggerFileUpload(); uploadMenuOpen = false"
class="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-2"
>
<i class="fas fa-file-upload w-4 text-center"></i>
{{ t('fileManager.actions.uploadFile') }}
</button>
<button
@click="triggerFolderUpload(); uploadMenuOpen = false"
:disabled="isFolderUploadBusy"
class="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-primary/10 hover:text-primary transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<i :class="isFolderUploadBusy ? 'fas fa-spinner fa-spin w-4 text-center' : 'fas fa-folder-open w-4 text-center'"></i>
{{ t('fileManager.actions.uploadFolder') }}
</button>
</div>
</div>
<button
@click="handleNewFolderContextMenuClick"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.newFolder')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="fas fa-folder-plus text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFolder') }}</span>
</button>
<button
@click="handleNewFileContextMenuClick"
:disabled="!currentSftpManager || !props.wsDeps.isConnected.value"
:title="t('fileManager.actions.newFile')"
class="flex items-center gap-1 px-2.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-header hover:enabled:border-primary hover:enabled:text-primary"
:class="{ 'px-1.5': props.isMobile }"
class="flex items-center justify-center w-7 h-7 text-text-secondary rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed hover:enabled:bg-black/10 hover:enabled:text-foreground"
>
<i class="far fa-file-alt text-sm"></i>
<span v-if="!props.isMobile">{{ t('fileManager.actions.newFile') }}</span>
</button>
<!-- 多选模式切换按钮 (仅移动端) -->
<button
v-if="props.isMobile"
@click="toggleMultiSelectMode"
:title="isMultiSelectMode ? t('fileManager.actions.exitMultiSelect', 'Exit Multi-Select Mode') : t('fileManager.actions.multiSelect', 'Enter Multi-Select Mode')"
class="flex items-center gap-1 px-1.5 py-1 bg-background border border-border rounded text-foreground text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
class="flex items-center justify-center w-7 h-7 rounded text-xs transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'hover:bg-header hover:border-primary hover:text-primary': !isMultiSelectMode,
'bg-primary text-white border-primary': isMultiSelectMode
'text-text-secondary hover:bg-black/10 hover:text-foreground': !isMultiSelectMode,
'bg-primary text-white': isMultiSelectMode
}"
>
<i class="fas fa-check-square text-sm"></i>
@@ -2442,13 +2454,8 @@ watch(
<div class="flex flex-grow min-h-0 overflow-hidden border-t border-border/60">
<div class="flex-1 bg-header/20 flex flex-col min-h-0">
<div class="px-3 py-3 border-b border-border/60">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-text-secondary">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
<div class="mt-1 text-xs text-text-secondary">1 {{ t('fileManager.explorer.rootCount', '个根目录') }}</div>
</div>
</div>
<div class="px-2 py-1.5 border-b border-border/60">
<div class="text-[11px] uppercase tracking-[0.15em] text-text-secondary font-medium">{{ t('fileManager.explorer.title', '目录资源管理器') }}</div>
</div>
<div
@@ -2473,24 +2480,24 @@ watch(
{{ t('fileManager.dropFilesHere', 'Drop files here to upload') }}
</div>
<div class="p-2 space-y-1">
<div class="px-1 py-0.5">
<div
v-for="row in explorerTreeRows"
:key="row.id"
:data-drop-path="row.path"
:data-is-directory="row.isDirectory"
:class="[
'group flex items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors cursor-pointer',
'group flex items-center gap-1.5 px-1 py-[3px] transition-colors cursor-pointer rounded-sm',
showExternalDropOverlay && externalDropTargetPath === row.path
? 'border-primary bg-primary/15 text-foreground'
? 'bg-primary/15 text-foreground'
: '',
isExplorerRowActive(row)
? 'bg-primary text-white border-primary shadow-sm'
? 'bg-emerald-600/20 text-emerald-400'
: isExplorerRowRelated(row)
? 'border-primary/20 bg-primary/8 text-foreground'
: 'border-transparent text-text-secondary hover:bg-background hover:text-foreground'
? 'bg-primary/8 text-foreground'
: 'text-text-secondary hover:bg-white/5 hover:text-foreground'
]"
:style="{ paddingLeft: `${0.6 + row.depth * 0.85}rem` }"
:style="{ paddingLeft: `${0.25 + row.depth * 0.75}rem` }"
@click="handleExplorerSelect(row)"
@dblclick="handleExplorerOpen(row)"
@contextmenu.prevent.stop="handleExplorerContextMenu($event, row)"
@@ -2498,42 +2505,38 @@ watch(
<button
v-if="row.isDirectory"
@click.stop="handleExplorerToggle(row)"
class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[10px]"
class="w-3.5 h-3.5 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60 hover:opacity-100"
>
<i :class="row.expanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</button>
<span v-else class="w-4 h-4 flex items-center justify-center flex-shrink-0 text-[9px] opacity-60">
<i class="fas fa-minus"></i>
</span>
<span v-else class="w-3.5 h-3.5 flex-shrink-0"></span>
<i
:class="[
row.isDirectory
? (row.isRoot ? 'fas fa-folder-tree' : 'fas fa-folder')
? (row.expanded ? 'fas fa-folder-open' : 'fas fa-folder')
: getFileIconClassBase(row.name),
'w-4 text-center flex-shrink-0',
isExplorerRowActive(row) ? 'text-white' : (row.isDirectory ? 'text-primary' : 'text-text-secondary')
'w-4 text-center flex-shrink-0 text-xs',
isExplorerRowActive(row)
? 'text-emerald-400'
: (row.isDirectory ? 'text-yellow-500' : 'text-text-secondary/70')
]"
></i>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium" :title="row.description || row.path">{{ row.name }}</div>
<div
v-if="row.isRoot || !row.isDirectory"
class="truncate text-[10px]"
:class="isExplorerRowActive(row) ? 'text-white/75' : 'text-text-secondary/80'"
>
{{ row.path }}
</div>
</div>
<span class="truncate text-[13px] leading-tight" :title="row.description || row.path">{{ row.name }}<span v-if="row.item?.attrs?.isSymbolicLink && row.description" class="text-text-secondary/60 ml-1 text-[11px]"> {{ row.description }}</span></span>
</div>
</div>
</div>
</div>
</div>
<!-- 使用 FileUploadPopup 组件 -->
<FileUploadPopup :uploads="uploads" @cancel-upload="cancelUpload" />
<!-- Transfer Panel -->
<TransferPanel
:uploads="uploads"
:visible="showTransferPanel"
@update:visible="showTransferPanel = $event"
@cancel-upload="cancelUpload"
/>
<FileManagerContextMenu
ref="contextMenuRef"
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<template>
<div ref="chartHostRef" class="cpu-history-chart">
<div class="cpu-history-chart__header">
<div ref="chartHostRef" class="cpu-history-chart" :class="{ 'cpu-history-chart--compact': compact }">
<div v-if="!compact" class="cpu-history-chart__header">
<div>
<h6 class="cpu-history-chart__title">{{ t('statusMonitor.cpuUsageTitle') }}</h6>
</div>
@@ -47,6 +47,10 @@ const props = defineProps({
type: Array as PropType<readonly (number | null)[]>,
required: true,
},
compact: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -98,7 +102,7 @@ const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
display: false,
},
tooltip: {
enabled: true,
enabled: !props.compact,
mode: 'index',
intersect: false,
callbacks: {
@@ -121,6 +125,7 @@ const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
},
},
y: {
display: !props.compact,
beginAtZero: true,
min: 0,
max: 100,
@@ -254,4 +259,17 @@ onBeforeUnmount(() => {
flex-direction: column;
}
}
.cpu-history-chart--compact {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
gap: 0;
grid-template-rows: minmax(0, 1fr);
}
.cpu-history-chart--compact .cpu-history-chart__canvas {
height: 100%;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div ref="chartHostRef" class="network-history-chart">
<div class="network-history-chart__header">
<div ref="chartHostRef" class="network-history-chart" :class="{ 'network-history-chart--compact': compact }">
<div v-if="!compact" class="network-history-chart__header">
<div>
<h6 class="network-history-chart__title">
{{ t('statusMonitor.networkSpeedTitleUnit', { unit: networkRateUnitIsMB ? 'MB/s' : 'KB/s' }) }}
@@ -64,6 +64,10 @@ const props = defineProps({
type: Array as PropType<readonly (number | null)[]>,
required: true,
},
compact: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -167,7 +171,7 @@ const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
display: false,
},
tooltip: {
enabled: true,
enabled: !props.compact,
mode: 'index',
intersect: false,
displayColors: true,
@@ -193,6 +197,7 @@ const networkChartOptions = computed<ChartOptions<'line'>>(() => ({
},
},
y: {
display: !props.compact,
beginAtZero: true,
min: 0,
max: suggestedYAxisMax.value,
@@ -348,4 +353,17 @@ onBeforeUnmount(() => {
justify-content: flex-start;
}
}
.network-history-chart--compact {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
gap: 0;
grid-template-rows: minmax(0, 1fr);
}
.network-history-chart--compact .network-history-chart__canvas {
height: 100%;
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { UploadItem } from '../types/upload.types';
interface TransferTask {
taskId: string;
status: string;
sourceItemName?: string;
progress?: number;
type: 'upload' | 'download' | 'transfer';
}
const props = defineProps<{
uploads: Record<string, UploadItem>;
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'cancel-upload', uploadId: string): void;
}>();
const { t } = useI18n();
const activeTab = ref<'all' | 'upload' | 'download'>('all');
const normalizedUploadTasks = computed<TransferTask[]>(() => {
return Object.values(props.uploads)
.filter(u => u.status !== 'success' && u.status !== 'cancelled')
.map(u => ({
taskId: u.id,
status: u.status,
sourceItemName: u.filename,
progress: u.progress,
type: 'upload' as const,
}));
});
const filteredTasks = computed(() => {
const all = normalizedUploadTasks.value;
if (activeTab.value === 'upload') return all.filter(t => t.type === 'upload');
if (activeTab.value === 'download') return all.filter(t => t.type === 'download');
return all;
});
const tabs = computed(() => [
{ key: 'all' as const, label: t('transferPanel.tabs.all', '全部'), count: normalizedUploadTasks.value.length },
{ key: 'upload' as const, label: t('transferPanel.tabs.upload', '上传'), count: normalizedUploadTasks.value.filter(t => t.type === 'upload').length },
{ key: 'download' as const, label: t('transferPanel.tabs.download', '下载'), count: normalizedUploadTasks.value.filter(t => t.type === 'download').length },
]);
const statusLabel = (status: string) => {
return t(`fileManager.uploadStatus.${status}`, status);
};
const handleCancel = (taskId: string) => {
emit('cancel-upload', taskId);
};
const togglePanel = () => {
emit('update:visible', !props.visible);
};
</script>
<template>
<div class="border-t border-border/60 flex-shrink-0 bg-header/30">
<div
class="flex items-center justify-between px-2 py-1 cursor-pointer hover:bg-white/5 transition-colors"
@click="togglePanel"
>
<div class="flex items-center gap-2">
<i class="fas fa-exchange-alt text-xs text-text-secondary"></i>
<span class="text-[11px] uppercase tracking-wider text-text-secondary font-medium">{{ t('transferPanel.title', '传输') }}</span>
<span v-if="normalizedUploadTasks.length > 0" class="text-[10px] bg-primary/20 text-primary px-1.5 rounded-full">{{ normalizedUploadTasks.length }}</span>
</div>
<i :class="visible ? 'fas fa-chevron-down' : 'fas fa-chevron-up'" class="text-[10px] text-text-secondary"></i>
</div>
<div v-if="visible" class="max-h-[180px] flex flex-col">
<div class="flex items-center gap-0.5 px-2 py-1 border-t border-border/40">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
:class="[
'px-2 py-0.5 text-[11px] rounded transition-colors',
activeTab === tab.key
? 'bg-primary/15 text-primary font-medium'
: 'text-text-secondary hover:text-foreground hover:bg-white/5'
]"
>
{{ tab.label }}
<span v-if="tab.count > 0" class="ml-1 text-[10px] opacity-70">{{ tab.count }}</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 py-1">
<div v-if="filteredTasks.length === 0" class="text-center py-6 text-text-secondary text-xs">
<i class="fas fa-inbox text-lg mb-1 block opacity-40"></i>
{{ t('transferPanel.empty', '暂无传输任务') }}
</div>
<div v-else class="space-y-1">
<div
v-for="task in filteredTasks"
:key="task.taskId"
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-white/5 text-xs group"
>
<i :class="task.type === 'upload' ? 'fas fa-arrow-up text-emerald-400' : 'fas fa-arrow-down text-blue-400'" class="w-3 text-center flex-shrink-0 text-[10px]"></i>
<span class="truncate flex-1 text-foreground" :title="task.sourceItemName">{{ task.sourceItemName }}</span>
<span class="text-text-secondary text-[10px] flex-shrink-0">{{ statusLabel(task.status) }}</span>
<div v-if="task.progress !== undefined && task.progress < 100" class="w-12 h-1 bg-border rounded-full flex-shrink-0 overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all" :style="{ width: task.progress + '%' }"></div>
</div>
<span v-if="task.progress !== undefined" class="text-[10px] text-text-secondary w-8 text-right flex-shrink-0">{{ task.progress }}%</span>
<button
v-if="['pending', 'uploading', 'compressing'].includes(task.status)"
@click.stop="handleCancel(task.taskId)"
class="opacity-0 group-hover:opacity-100 text-text-secondary hover:text-red-400 transition-all flex-shrink-0"
:title="t('fileManager.actions.cancel')"
>
<i class="fas fa-times text-[10px]"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -4,6 +4,13 @@ import { sessions as globalSessionsRef } from '../stores/session/state';
import type { Terminal } from 'xterm';
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
import {
SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS,
createSshCommandRuntimeSnapshot,
isSshCommandRuntimeActive,
type SshCommandRuntimePhase,
type SshCommandRuntimeReason,
} from '../stores/session/runtime';
export interface SshTerminalDependencies {
sendMessage: (message: WebSocketMessage) => void;
@@ -32,14 +39,41 @@ const stripTerminalControlSequences = (text: string): string =>
const getSessionState = (sessionId: string) => globalSessionsRef.value.get(sessionId);
const resetSessionCommandRuntime = (sessionId: string) => {
const setSessionCommandRuntime = (
sessionId: string,
nextPhase: SshCommandRuntimePhase,
reason: SshCommandRuntimeReason,
timestamp: number,
visibleUntil?: number,
) => {
const session = getSessionState(sessionId);
if (!session) {
return;
}
const currentRuntime = session.commandRuntime.value ?? createSshCommandRuntimeSnapshot();
session.commandRuntime.value = {
...currentRuntime,
phase: nextPhase,
reason,
lastTransitionAt: timestamp,
visibleUntil: visibleUntil ?? currentRuntime.visibleUntil,
};
};
const clearSessionCommandRuntime = (
sessionId: string,
reason: Extract<SshCommandRuntimeReason, 'prompt' | 'interrupt' | 'disconnect' | 'error' | 'connected' | 'input'>,
nextPhase: Extract<SshCommandRuntimePhase, 'idle' | 'disconnected' | 'error'>,
timestamp: number,
) => {
const session = getSessionState(sessionId);
if (!session) {
return;
}
session.isCommandRunning.value = false;
session.terminalInputBuffer.value = '';
setSessionCommandRuntime(sessionId, nextPhase, reason, timestamp, 0);
};
const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) => {
@@ -53,13 +87,14 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
return { submittedCommand: false, interrupted: false };
}
const now = Date.now();
const currentPhase = session.commandRuntime.value.phase;
let nextBuffer = session.terminalInputBuffer.value;
let submittedCommand = false;
let interrupted = false;
for (const char of normalizedData) {
if (char === '\x03') {
session.isCommandRunning.value = false;
nextBuffer = '';
interrupted = true;
continue;
@@ -67,7 +102,6 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
if (char === '\r' || char === '\n') {
if (nextBuffer.trim().length > 0) {
session.isCommandRunning.value = true;
submittedCommand = true;
}
nextBuffer = '';
@@ -83,14 +117,36 @@ const syncSessionCommandRuntimeFromInput = (sessionId: string, data: string) =>
continue;
}
if (nextBuffer.length === 0 && session.isCommandRunning.value) {
session.isCommandRunning.value = false;
}
nextBuffer += char;
}
session.terminalInputBuffer.value = nextBuffer;
if (interrupted) {
clearSessionCommandRuntime(sessionId, 'interrupt', 'idle', now);
return { submittedCommand: false, interrupted: true };
}
if (submittedCommand) {
const nextPhase = isSshCommandRuntimeActive(currentPhase) ? 'running' : 'pending';
setSessionCommandRuntime(
sessionId,
nextPhase,
'submit',
now,
Math.max(session.commandRuntime.value.visibleUntil, now + SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS),
);
return { submittedCommand: true, interrupted: false };
}
if (nextBuffer.length > 0) {
if (!isSshCommandRuntimeActive(currentPhase) && currentPhase !== 'disconnected' && currentPhase !== 'error') {
setSessionCommandRuntime(sessionId, 'typing', 'input', now);
}
} else if (currentPhase === 'typing') {
clearSessionCommandRuntime(sessionId, 'input', 'idle', now);
}
return { submittedCommand, interrupted };
};
@@ -139,6 +195,42 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
const terminalOutputBuffer = ref<(string | Uint8Array)[]>([]);
const isSshConnected = ref(false);
const promptProbeBuffer = ref('');
let runtimeResolutionTimer: ReturnType<typeof setTimeout> | null = null;
const clearRuntimeResolutionTimer = () => {
if (runtimeResolutionTimer !== null) {
clearTimeout(runtimeResolutionTimer);
runtimeResolutionTimer = null;
}
};
const resolveRuntimePhase = (
nextPhase: Extract<SshCommandRuntimePhase, 'idle' | 'disconnected' | 'error'>,
reason: Extract<SshCommandRuntimeReason, 'connected' | 'disconnect' | 'error'>,
) => {
clearRuntimeResolutionTimer();
clearSessionCommandRuntime(sessionId, reason, nextPhase, Date.now());
};
const schedulePromptResolution = () => {
const session = getSessionState(sessionId);
if (!session) {
return;
}
clearRuntimeResolutionTimer();
const now = Date.now();
const delay = Math.max(0, session.commandRuntime.value.visibleUntil - now);
if (delay === 0) {
clearSessionCommandRuntime(sessionId, 'prompt', 'idle', now);
return;
}
runtimeResolutionTimer = setTimeout(() => {
runtimeResolutionTimer = null;
clearSessionCommandRuntime(sessionId, 'prompt', 'idle', Date.now());
}, delay);
};
const getTerminalText = (key: string, params?: Record<string, unknown>): string => {
const translationKey = `workspace.terminal.${key}`;
@@ -176,6 +268,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
promptProbeBuffer.value = '';
clearRuntimeResolutionTimer();
} else if (runtimeResolutionTimer !== null && (getSessionState(sessionId)?.terminalInputBuffer.value.length ?? 0) > 0) {
clearRuntimeResolutionTimer();
setSessionCommandRuntime(sessionId, 'typing', 'input', Date.now(), 0);
}
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
};
@@ -222,12 +318,21 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
terminalOutputBuffer.value.push(outputData);
}
if (getSessionState(sessionId)?.isCommandRunning.value) {
const session = getSessionState(sessionId);
if (session && isSshCommandRuntimeActive(session.commandRuntime.value.phase)) {
const promptProbeText = getPromptProbeText(outputData);
if (promptProbeText) {
clearRuntimeResolutionTimer();
promptProbeBuffer.value = `${promptProbeBuffer.value}${promptProbeText}`.slice(-320);
if (isPromptTail(promptProbeBuffer.value)) {
resetSessionCommandRuntime(sessionId);
schedulePromptResolution();
promptProbeBuffer.value = '';
return;
}
const strippedOutput = stripTerminalControlSequences(promptProbeText).trim();
if (strippedOutput && session.commandRuntime.value.phase === 'pending') {
setSessionCommandRuntime(sessionId, 'running', 'output', Date.now());
}
}
}
@@ -241,6 +346,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。 Payload:`, payload, 'Full message:', message);
isSshConnected.value = true;
promptProbeBuffer.value = '';
resolveRuntimePhase('idle', 'connected');
terminalInstance.value?.focus();
if (terminalInstance.value) {
@@ -271,7 +377,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
isSshConnected.value = false;
promptProbeBuffer.value = '';
resetSessionCommandRuntime(sessionId);
resolveRuntimePhase('disconnected', 'disconnect');
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
};
@@ -284,7 +390,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
isSshConnected.value = false;
promptProbeBuffer.value = '';
resetSessionCommandRuntime(sessionId);
resolveRuntimePhase('error', 'error');
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
};
@@ -339,6 +445,7 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
registerSshHandlers();
const cleanup = () => {
clearRuntimeResolutionTimer();
unregisterAllSshHandlers();
terminalInstance.value = null;
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
@@ -352,6 +459,10 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
const runtimeUpdate = syncSessionCommandRuntimeFromInput(sessionId, data);
if (runtimeUpdate.submittedCommand || runtimeUpdate.interrupted) {
promptProbeBuffer.value = '';
clearRuntimeResolutionTimer();
} else if (runtimeResolutionTimer !== null && (getSessionState(sessionId)?.terminalInputBuffer.value.length ?? 0) > 0) {
clearRuntimeResolutionTimer();
setSessionCommandRuntime(sessionId, 'typing', 'input', Date.now(), 0);
}
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
};
+35 -15
View File
@@ -1861,6 +1861,15 @@
"unknownTargetPath": "[Unknown Target Path]",
"taskIdFallback": "Task ID: {taskId}"
},
"transferPanel": {
"title": "Transfer",
"tabs": {
"all": "All",
"upload": "Upload",
"download": "Download"
},
"empty": "No transfer tasks"
},
"sendFilesModal": {
"title": "Send Files",
"searchConnectionsPlaceholder": "Search connections...",
@@ -1885,34 +1894,45 @@
"transferFailedError": "Failed to initiate transfer. Please try again."
},
"favoritePaths": {
"title": "Bookmarks",
"addEditForm": {
"validation": {
"pathRequired": "Path is required."
},
"editTitle": "Edit Favorite Path",
"addTitle": "Add New Favorite Path",
"editTitle": "Edit Bookmark",
"addTitle": "Add Bookmark",
"pathLabel": "Path",
"pathPlaceholder": "/example/folder/path",
"nameLabel": "Name (Optional)",
"namePlaceholder": "My Documents",
"scopeLabel": "Scope",
"errors": {
"genericSaveError": "Failed to save favorite path."
"genericSaveError": "Failed to save bookmark."
}
},
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
"searchPlaceholder": "Search by name or path...",
"addNew": "Add new favorite path",
"loading": "Loading favorites...",
"noResults": "No matching favorites found.",
"noFavorites": "No favorite paths yet. Add one!",
"searchPlaceholder": "Search bookmarks...",
"addNew": "Add Bookmark",
"loading": "Loading bookmarks...",
"noResults": "No matching bookmarks found.",
"noFavorites": "No bookmarks yet",
"sendToTerminal": "Send to Terminal",
"sortByName": "Sort by name",
"sortByUsage": "Sort by usage",
"scopeAll": "All",
"scopeLocal": "Local",
"scopeGlobal": "Cloud",
"scopeLocalLabel": "This server only",
"scopeGlobalLabel": "Global shared",
"notifications": {
"fetchError": "Failed to load favorite paths.",
"addSuccess": "Favorite path added successfully.",
"addError": "Failed to add favorite path.",
"updateSuccess": "Favorite path updated successfully.",
"updateError": "Failed to update favorite path.",
"deleteSuccess": "Favorite path deleted successfully.",
"deleteError": "Failed to delete favorite path."
"fetchError": "Failed to load bookmarks.",
"addSuccess": "Bookmark added successfully.",
"addError": "Failed to add bookmark.",
"updateSuccess": "Bookmark updated successfully.",
"updateError": "Failed to update bookmark.",
"deleteSuccess": "Bookmark deleted successfully.",
"deleteError": "Failed to delete bookmark.",
"markAsUsedError": "Failed to update usage time."
}
},
"pathHistory": {
+40 -20
View File
@@ -1799,6 +1799,15 @@
"unknownTargetPath": "[宛先パス不明]",
"taskIdFallback": "タスクID: {taskId}"
},
"transferPanel": {
"title": "転送",
"tabs": {
"all": "すべて",
"upload": "アップロード",
"download": "ダウンロード"
},
"empty": "転送タスクはありません"
},
"sendFilesModal": {
"title": "ファイル送信",
"searchConnectionsPlaceholder": "接続を検索...",
@@ -1823,34 +1832,45 @@
"transferFailedError": "転送の開始に失敗しました。もう一度お試しください。"
},
"favoritePaths": {
"title": "ブックマーク",
"addEditForm": {
"validation": {
"pathRequired": "Path is required."
"pathRequired": "パスは必須です。"
},
"editTitle": "Edit Favorite Path",
"addTitle": "Add New Favorite Path",
"pathLabel": "Path",
"editTitle": "ブックマークを編集",
"addTitle": "ブックマークを追加",
"pathLabel": "パス",
"pathPlaceholder": "/example/folder/path",
"nameLabel": "Name (Optional)",
"namePlaceholder": "My Documents",
"nameLabel": "名前(任意)",
"namePlaceholder": "マイドキュメント",
"scopeLabel": "保存場所",
"errors": {
"genericSaveError": "Failed to save favorite path."
"genericSaveError": "ブックマークの保存に失敗しました。"
}
},
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
"searchPlaceholder": "Search by name or path...",
"addNew": "Add new favorite path",
"loading": "Loading favorites...",
"noResults": "No matching favorites found.",
"noFavorites": "No favorite paths yet. Add one!",
"confirmDelete": "「{name}」を削除してもよろしいですか?",
"searchPlaceholder": "ブックマークを検索...",
"addNew": "ブックマークを追加",
"loading": "ブックマークを読み込み中...",
"noResults": "一致するブックマークが見つかりません。",
"noFavorites": "ブックマークはありません",
"sendToTerminal": "ターミナルに送信",
"sortByName": "名前順",
"sortByUsage": "使用順",
"scopeAll": "すべて",
"scopeLocal": "ローカル",
"scopeGlobal": "クラウド",
"scopeLocalLabel": "このサーバーのみ",
"scopeGlobalLabel": "グローバル共有",
"notifications": {
"fetchError": "Failed to load favorite paths.",
"addSuccess": "Favorite path added successfully.",
"addError": "Failed to add favorite path.",
"updateSuccess": "Favorite path updated successfully.",
"updateError": "Failed to update favorite path.",
"deleteSuccess": "Favorite path deleted successfully.",
"deleteError": "Failed to delete favorite path."
"fetchError": "ブックマークの読み込みに失敗しました。",
"addSuccess": "ブックマークを追加しました。",
"addError": "ブックマークの追加に失敗しました。",
"updateSuccess": "ブックマークを更新しました。",
"updateError": "ブックマークの更新に失敗しました。",
"deleteSuccess": "ブックマークを削除しました。",
"deleteError": "ブックマークの削除に失敗しました。",
"markAsUsedError": "使用時間の更新に失敗しました。"
}
},
"pathHistory": {
+36 -16
View File
@@ -1834,6 +1834,15 @@
"unknownTargetPath": "[目标路径未知]",
"taskIdFallback": "任务ID: {taskId}"
},
"transferPanel": {
"title": "传输",
"tabs": {
"all": "全部",
"upload": "上传",
"download": "下载"
},
"empty": "暂无传输任务"
},
"sendFilesModal": {
"title": "发送文件",
"searchConnectionsPlaceholder": "搜索连接...",
@@ -1890,34 +1899,45 @@
}
},
"favoritePaths": {
"title": "书签列表",
"addEditForm": {
"validation": {
"pathRequired": "路径不能为空。"
},
"editTitle": "编辑收藏路径",
"addTitle": "添加新收藏路径",
"editTitle": "编辑书签",
"addTitle": "添加书签",
"pathLabel": "路径",
"pathPlaceholder": "/example/folder/path",
"nameLabel": "名称 (可选)",
"nameLabel": "名称可选",
"namePlaceholder": "我的文档",
"scopeLabel": "记录位置",
"errors": {
"genericSaveError": "保存收藏路径失败。"
"genericSaveError": "保存书签失败。"
}
},
"confirmDelete": "您确定要删除 \"{name}\" 吗?",
"searchPlaceholder": "按名称或路径搜索...",
"addNew": "添加新收藏路径",
"loading": "正在加载收藏...",
"noResults": "未找到匹配的收藏。",
"noFavorites": "还没有收藏路径,快添加一个吧!",
"searchPlaceholder": "搜索书签...",
"addNew": "添加书签",
"loading": "正在加载书签...",
"noResults": "未找到匹配的书签。",
"noFavorites": "暂无书签",
"sendToTerminal": "发送到终端",
"sortByName": "按名称排序",
"sortByUsage": "按使用排序",
"scopeAll": "全部",
"scopeLocal": "本地",
"scopeGlobal": "云端",
"scopeLocalLabel": "仅当前服务器",
"scopeGlobalLabel": "全局共享",
"notifications": {
"fetchError": "加载收藏路径失败。",
"addSuccess": "收藏路径添加成功。",
"addError": "添加收藏路径失败。",
"updateSuccess": "收藏路径更新成功。",
"updateError": "更新收藏路径失败。",
"deleteSuccess": "收藏路径删除成功。",
"deleteError": "删除收藏路径失败。"
"fetchError": "加载书签失败。",
"addSuccess": "书签添加成功。",
"addError": "添加书签失败。",
"updateSuccess": "书签更新成功。",
"updateError": "更新书签失败。",
"deleteSuccess": "书签删除成功。",
"deleteError": "删除书签失败。",
"markAsUsedError": "更新使用时间失败。"
}
},
"pathHistory": {
@@ -9,8 +9,9 @@ export interface FavoritePathItem {
id: string;
path: string;
name?: string;
last_used_at?: number | null; // Added last_used_at
// Add other relevant fields from the API if any
scope?: string;
connection_id?: number | null;
last_used_at?: number | null;
createdAt?: string;
updatedAt?: string;
}
@@ -20,7 +21,8 @@ export interface FavoritePathsState {
isLoading: boolean;
error: string | null;
searchTerm: string;
currentSortBy: FavoritePathSortType;
currentSortBy: FavoritePathSortType;
activeScope: 'all' | 'local' | 'global';
isInitialized: boolean;
}
@@ -33,18 +35,24 @@ export const useFavoritePathsStore = defineStore('favoritePaths', {
error: null,
searchTerm: '',
currentSortBy: savedSortBy || 'name',
activeScope: 'all',
isInitialized: false,
};
},
getters: {
// The filteredFavoritePaths getter will now operate on the already sorted list
filteredFavoritePaths(state): FavoritePathItem[] {
let paths = state.favoritePaths;
if (state.activeScope === 'local') {
paths = paths.filter(fav => fav.scope === 'local');
} else if (state.activeScope === 'global') {
paths = paths.filter(fav => !fav.scope || fav.scope === 'global');
}
if (!state.searchTerm) {
return state.favoritePaths;
return paths;
}
const lowerCaseSearchTerm = state.searchTerm.toLowerCase();
// Note: state.favoritePaths is now always sorted by this.currentSortBy
return state.favoritePaths.filter(fav =>
return paths.filter(fav =>
fav.path.toLowerCase().includes(lowerCaseSearchTerm) ||
(fav.name && fav.name.toLowerCase().includes(lowerCaseSearchTerm))
);
@@ -101,7 +109,10 @@ export const useFavoritePathsStore = defineStore('favoritePaths', {
setSortBy(sortBy: FavoritePathSortType) {
this.currentSortBy = sortBy;
localStorage.setItem('favoritePathSortBy', sortBy);
this._sortFavoritePaths(); // Re-sort locally
this._sortFavoritePaths();
},
setActiveScope(scope: 'all' | 'local' | 'global') {
this.activeScope = scope;
},
async markPathAsUsed(pathId: string, t: (key: string, defaultMessage: string) => string) {
const notificationsStore = useUiNotificationsStore();
@@ -14,6 +14,7 @@ import { createSshTerminalManager, type SshTerminalDependencies } from '../../..
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../../../composables/useStatusMonitor';
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
import { registerSshSuspendHandlers } from './sshSuspendActions';
import { createSshCommandRuntimeSnapshot } from '../runtime';
const SESSION_ORDER_STORAGE_KEY = 'sessionOrder';
@@ -148,7 +149,7 @@ export const openNewSession = (
editorTabs: ref([]),
activeEditorTabId: ref(null),
commandInputContent: ref(''),
isCommandRunning: ref(false),
commandRuntime: ref(createSshCommandRuntimeSnapshot()),
terminalInputBuffer: ref(''),
isMarkedForSuspend: false,
createdAt: Date.now(),
@@ -1,6 +1,7 @@
import { computed } from 'vue';
import { sessions, activeSessionId } from './state';
import type { SessionState, SessionTabInfoWithStatus } from './types';
import { isSshCommandRuntimeActive } from './runtime';
export const sessionTabs = computed(() => {
return Array.from(sessions.value.values()).map((session) => ({
@@ -54,7 +55,8 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value,
isMarkedForSuspend: session.isMarkedForSuspend,
isCommandRunning: session.isCommandRunning.value,
commandRuntimePhase: session.commandRuntime.value.phase,
isCommandRunning: isSshCommandRuntimeActive(session.commandRuntime.value.phase),
}));
});
@@ -0,0 +1,40 @@
export const SSH_COMMAND_RUNTIME_MIN_VISIBLE_MS = 350;
export type SshCommandRuntimePhase =
| 'idle'
| 'typing'
| 'pending'
| 'running'
| 'disconnected'
| 'error';
export type SshCommandRuntimeReason =
| 'init'
| 'connected'
| 'input'
| 'submit'
| 'output'
| 'prompt'
| 'interrupt'
| 'disconnect'
| 'error';
export interface SshCommandRuntimeSnapshot {
phase: SshCommandRuntimePhase;
reason: SshCommandRuntimeReason;
lastTransitionAt: number;
visibleUntil: number;
}
export const createSshCommandRuntimeSnapshot = (
overrides: Partial<SshCommandRuntimeSnapshot> = {},
): SshCommandRuntimeSnapshot => ({
phase: 'idle',
reason: 'init',
lastTransitionAt: 0,
visibleUntil: 0,
...overrides,
});
export const isSshCommandRuntimeActive = (phase: SshCommandRuntimePhase): boolean =>
phase === 'pending' || phase === 'running';
@@ -6,6 +6,7 @@ import type { createWebSocketConnectionManager } from '../../composables/useWebS
import type { createSftpActionsManager } from '../../composables/useSftpActions';
import type { createSshTerminalManager } from '../../composables/useSshTerminal';
import type { createStatusMonitorManager } from '../../composables/useStatusMonitor';
import type { SshCommandRuntimePhase, SshCommandRuntimeSnapshot } from './runtime';
export type WsManagerInstance = ReturnType<typeof createWebSocketConnectionManager>;
export type SftpManagerInstance = ReturnType<typeof createSftpActionsManager>;
@@ -27,7 +28,7 @@ export interface SessionState {
editorTabs: Ref<FileTab[]>;
activeEditorTabId: Ref<string | null>;
commandInputContent: Ref<string>;
isCommandRunning: Ref<boolean>;
commandRuntime: Ref<SshCommandRuntimeSnapshot>;
terminalInputBuffer: Ref<string>;
isResuming?: boolean;
isMarkedForSuspend?: boolean;
@@ -43,5 +44,6 @@ export interface SessionTabInfoWithStatus {
terminalIndex: number;
status: WsConnectionStatus;
isMarkedForSuspend?: boolean;
commandRuntimePhase: SshCommandRuntimePhase;
isCommandRunning: boolean;
}