Files
nexus-terminal/packages/frontend/src/components/FileEditorContainer.vue
T

483 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, type PropType, ref, watch, defineExpose, onMounted, onBeforeUnmount, nextTick } from 'vue'; // 添加 nextTick
import { useI18n } from 'vue-i18n';
// import { storeToRefs } from 'pinia'; // 移除 storeToRefs
import MonacoEditor from './MonacoEditor.vue'; // 导入 Monaco Editor 组件
import FileEditorTabs from './FileEditorTabs.vue'; // 导入标签栏组件 (路径确认无误)
// import { useFileEditorStore } from '../stores/fileEditor.store'; // 移除 Store 导入
import type { FileTab } from '../stores/fileEditor.store'; // 保留类型导入
import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; // +++ 导入焦点切换 Store +++
import { useSessionStore } from '../stores/session.store'; // +++ 导入会话 Store +++
import { useSettingsStore } from '../stores/settings.store'; // +++ 导入设置 Store +++
import { storeToRefs } from 'pinia'; // +++ 导入 storeToRefs +++
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++
const { t } = useI18n();
const emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ 获取事件发射器 +++
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const sessionStore = useSessionStore(); // +++ 实例化会话 Store +++
const settingsStore = useSettingsStore(); // +++ 实例化设置 Store +++
const { shareFileEditorTabsBoolean } = storeToRefs(settingsStore); // +++ 获取共享设置 +++
// --- Props ---
const props = defineProps({
tabs: {
type: Array as PropType<FileTab[]>,
required: true,
},
activeTabId: {
type: String as PropType<string | null>,
default: null,
},
sessionId: { // 需要 sessionId 来区分保存请求等 (虽然 tabs 里也有)
type: String as PropType<string | null>,
default: null,
},
});
// --- 计算属性,用于模板绑定 ---
const activeTab = computed((): FileTab | null => {
if (!props.activeTabId) return null;
return props.tabs.find(tab => tab.id === props.activeTabId) ?? null;
});
// Monaco Editor 的 v-model 处理
const localEditorContent = ref('');
const encodingSelectRef = ref<HTMLSelectElement | null>(null); // Ref for the select element
// Function to calculate and set the select width
const updateSelectWidth = () => {
nextTick(() => { // Ensure DOM is updated before measuring
if (!encodingSelectRef.value) return;
const selectElement = encodingSelectRef.value;
const selectedOption = selectElement.options[selectElement.selectedIndex];
if (!selectedOption) return;
// Create a temporary span to measure text width
const tempSpan = document.createElement('span');
// Copy relevant styles (adjust as needed for accurate measurement)
const styles = window.getComputedStyle(selectElement);
tempSpan.style.fontSize = styles.fontSize;
tempSpan.style.fontFamily = styles.fontFamily;
tempSpan.style.fontWeight = styles.fontWeight;
tempSpan.style.letterSpacing = styles.letterSpacing;
tempSpan.style.paddingLeft = styles.paddingLeft; // Include padding for accuracy
tempSpan.style.paddingRight = styles.paddingRight;
// tempSpan.style.borderLeftWidth = styles.borderLeftWidth; // Border might not be needed for width calc
// tempSpan.style.borderRightWidth = styles.borderRightWidth;
tempSpan.style.visibility = 'hidden'; // Make it invisible
tempSpan.style.position = 'absolute'; // Prevent layout shift
tempSpan.style.whiteSpace = 'nowrap'; // Prevent wrapping
tempSpan.style.left = '-9999px'; // Move off-screen
tempSpan.textContent = selectedOption.text;
document.body.appendChild(tempSpan);
const textWidth = tempSpan.offsetWidth;
document.body.removeChild(tempSpan);
// Set the select width (add extra space for dropdown arrow, adjust as needed)
const arrowPadding = 25; // Increased padding for arrow and visual spacing
selectElement.style.width = `${textWidth + arrowPadding}px`;
// console.log(`[EditorContainer] Setting select width for "${selectedOption.text}" to ${textWidth + arrowPadding}px`);
});
};
// 监听 activeTab 的变化,重置 localEditorContent 并更新 select 宽度
watch(activeTab, (newTab) => {
// console.log('[EditorContainer] Active tab changed, updating local content.');
localEditorContent.value = newTab?.content ?? '';
updateSelectWidth(); // Update select width when tab changes
}, { immediate: true }); // immediate: true ensures it runs on initial load too
// 移除用于调试的 watch 函数
// 当本地编辑器内容变化时,通知父组件 (WorkspaceView)
watch(localEditorContent, (newContent) => {
// console.log('[EditorContainer] Local content changed, checking if emit needed.');
if (activeTab.value && newContent !== activeTab.value.content) {
// console.log(`[EditorContainer] Emitting update:content for tab ${activeTab.value.id}`);
// 只有当内容实际改变时才发出事件
emitWorkspaceEvent('editor:updateContent', { tabId: activeTab.value.id, content: newContent });
// 注意:isModified 状态应该由 Store 根据 content 和 originalContent 计算
}
});
// orderedTabs 直接使用 props
const orderedTabs = computed(() => props.tabs);
const currentTabIsLoading = computed(() => activeTab.value?.isLoading ?? false);
const currentTabLoadingError = computed(() => activeTab.value?.loadingError ?? null);
const currentTabIsSaving = computed(() => activeTab.value?.isSaving ?? false);
const currentTabSaveStatus = computed(() => activeTab.value?.saveStatus ?? 'idle');
const currentTabSaveError = computed(() => activeTab.value?.saveError ?? null);
const currentTabLanguage = computed(() => activeTab.value?.language ?? 'plaintext');
const currentTabFilePath = computed(() => activeTab.value?.filePath ?? '');
const currentTabIsModified = computed(() => activeTab.value?.isModified ?? false); // 用于显示修改状态
// +++ 新增:计算当前选择的编码 +++
const currentSelectedEncoding = computed(() => activeTab.value?.selectedEncoding ?? 'utf-8');
// +++ 新增:计算当前活动标签的会话名称 +++
const currentTabSessionName = computed(() => {
const sessionId = activeTab.value?.sessionId;
if (!sessionId) return null;
return sessionStore.sessions.get(sessionId)?.connectionName ?? null; // 修正:使用 connectionName
});
// Watch for changes in the selected encoding to update width
watch(currentSelectedEncoding, () => {
updateSelectWidth();
});
// +++ 新增:编码选项 +++
// 注意:这里的 value 需要与 iconv-lite 支持的标签匹配 (后端使用)
// 扩展编码列表以包含更多常用选项
const encodingOptions = ref([
// Unicode
{ value: 'utf-8', text: 'UTF-8' },
{ value: 'utf-16le', text: 'UTF-16 LE' },
{ value: 'utf-16be', text: 'UTF-16 BE' },
// Chinese
{ value: 'gbk', text: 'GBK' },
{ value: 'gb18030', text: 'GB18030' },
{ value: 'big5', text: 'Big5 (Traditional Chinese)' },
// Japanese
{ value: 'shift_jis', text: 'Shift-JIS' },
{ value: 'euc-jp', text: 'EUC-JP' },
// Korean
{ value: 'euc-kr', text: 'EUC-KR' },
// Western European
{ value: 'iso-8859-1', text: 'ISO-8859-1 (Latin-1)' },
{ value: 'iso-8859-15', text: 'ISO-8859-15 (Latin-9)' },
{ value: 'cp1252', text: 'Windows-1252' }, // Western European
// Central European
{ value: 'iso-8859-2', text: 'ISO-8859-2 (Latin-2)' },
{ value: 'cp1250', text: 'Windows-1250' }, // Central European
// Cyrillic
{ value: 'iso-8859-5', text: 'ISO-8859-5 (Cyrillic)' },
{ value: 'cp1251', text: 'Windows-1251 (Cyrillic)' },
{ value: 'koi8-r', text: 'KOI8-R' },
{ value: 'koi8-u', text: 'KOI8-U' },
// Greek
{ value: 'iso-8859-7', text: 'ISO-8859-7 (Greek)' },
{ value: 'cp1253', text: 'Windows-1253 (Greek)' },
// Turkish
{ value: 'iso-8859-9', text: 'ISO-8859-9 (Turkish)' },
{ value: 'cp1254', text: 'Windows-1254 (Turkish)' },
// Hebrew
{ value: 'iso-8859-8', text: 'ISO-8859-8 (Hebrew)' },
{ value: 'cp1255', text: 'Windows-1255 (Hebrew)' },
// Arabic
{ value: 'iso-8859-6', text: 'ISO-8859-6 (Arabic)' },
{ value: 'cp1256', text: 'Windows-1256 (Arabic)' },
// Baltic
{ value: 'iso-8859-4', text: 'ISO-8859-4 (Baltic)' }, // Latin-4
{ value: 'iso-8859-13', text: 'ISO-8859-13 (Baltic)' }, // Latin-7
{ value: 'cp1257', text: 'Windows-1257 (Baltic)' },
// Vietnamese
{ value: 'cp1258', text: 'Windows-1258 (Vietnamese)' },
// Thai
{ value: 'tis-620', text: 'TIS-620 (Thai)' }, // Often cp874
{ value: 'cp874', text: 'Windows-874 (Thai)' },
]);
// --- 事件处理 ---
const handleSaveRequest = () => {
if (activeTab.value) {
emitWorkspaceEvent('editor:saveTab', { tabId: activeTab.value.id }); // 发出保存请求事件
}
};
// +++ 新增:处理编码更改事件 +++
const handleEncodingChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const newEncoding = target.value;
if (activeTab.value && newEncoding && newEncoding !== currentSelectedEncoding.value) {
console.log(`[EditorContainer] Encoding changed to ${newEncoding} for tab ${activeTab.value.id}`);
emitWorkspaceEvent('editor:changeEncoding', { tabId: activeTab.value.id, encoding: newEncoding });
}
};
// 注意:关闭/最小化按钮现在应该在 WorkspaceView 控制 Pane,而不是这里
// const handleCloseContainer = () => { ... };
// const handleMinimizeContainer = () => { ... };
// 新增:Monaco Editor 组件的引用
const monacoEditorRef = ref<InstanceType<typeof MonacoEditor> | null>(null);
// 新增:聚焦活动编辑器的方法
const focusActiveEditor = (): boolean => {
if (monacoEditorRef.value) {
monacoEditorRef.value.focus();
return true; // 聚焦成功
}
return false; // 聚焦失败
};
// 新增:暴露聚焦方法
defineExpose({ focusActiveEditor });
// +++ 注册/注销自定义聚焦动作 +++
let unregisterFocusFn: (() => void) | null = null; // 保存注销函数
onMounted(() => {
// 注册动作并保存返回的注销函数
unregisterFocusFn = focusSwitcherStore.registerFocusAction('fileEditorActive', focusActiveEditor);
// +++ 添加键盘事件监听器 +++
window.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
// 调用保存的注销函数(如果存在)
if (unregisterFocusFn) {
unregisterFocusFn();
}
// +++ 移除键盘事件监听器 +++
window.removeEventListener('keydown', handleKeyDown);
});
// +++ 新增:处理键盘事件以切换标签 +++
const handleKeyDown = (event: KeyboardEvent) => {
// 检查是否在编辑器内部或其容器内触发(避免全局冲突)
// 这里简化处理,假设只要此组件挂载就监听,更精确的判断可能需要检查 event.target
if (event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
event.preventDefault();
event.stopPropagation();
if (!props.activeTabId || props.tabs.length <= 1) {
return; // 没有活动标签或只有一个标签,无需切换
}
const currentIndex = props.tabs.findIndex(tab => tab.id === props.activeTabId);
if (currentIndex === -1) {
return; // 未找到当前标签索引
}
let nextIndex: number;
if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + props.tabs.length) % props.tabs.length;
} else { // ArrowRight
nextIndex = (currentIndex + 1) % props.tabs.length;
}
const nextTabId = props.tabs[nextIndex]?.id;
if (nextTabId) {
emitWorkspaceEvent('editor:activateTab', { tabId: nextTabId });
}
}
};
</script>
<template>
<!-- 这个容器不再控制自己的显示/隐藏 WorkspaceView Pane 控制 -->
<div class="file-editor-container">
<!-- 1. 标签栏 -->
<FileEditorTabs
:tabs="orderedTabs"
:active-tab-id="props.activeTabId"
@activate-tab="(tabId: string) => emitWorkspaceEvent('editor:activateTab', { tabId })"
@close-tab="(tabId: string) => emitWorkspaceEvent('editor:closeTab', { tabId })"
@close-other-tabs="(tabId: string) => emitWorkspaceEvent('editor:closeOtherTabs', { tabId })"
@close-tabs-to-right="(tabId: string) => emitWorkspaceEvent('editor:closeTabsToRight', { tabId })"
@close-tabs-to-left="(tabId: string) => emitWorkspaceEvent('editor:closeTabsToLeft', { tabId })"
/>
<!-- 2. 编辑器头部 (显示当前激活标签信息) -->
<!-- 移除关闭/最小化按钮,这些由 WorkspaceView 控制 -->
<div v-if="activeTab" class="editor-header">
<span>
{{ t('fileManager.editingFile') }}<template v-if="shareFileEditorTabsBoolean && currentTabSessionName">({{ currentTabSessionName }})</template>: {{ currentTabFilePath }}
<span v-if="currentTabIsModified" class="modified-indicator">*</span>
</span>
<div class="editor-actions">
<!-- +++ 新增:编码选择下拉菜单 +++ -->
<div class="encoding-select-wrapper" v-if="activeTab && !currentTabIsLoading">
<select
ref="encodingSelectRef"
:value="currentSelectedEncoding"
@change="handleEncodingChange"
class="encoding-select"
:title="t('fileManager.changeEncodingTooltip', '更改文件编码')"
>
<option v-for="option in encodingOptions" :key="option.value" :value="option.value">
{{ option.text }}
</option>
</select>
</div>
<span v-else-if="activeTab" class="encoding-select-placeholder">{{ t('fileManager.loadingEncoding', '加载中...') }}</span>
<!-- +++ 结束新增 +++ -->
<span v-if="currentTabSaveStatus === 'saving'" class="save-status saving">{{ t('fileManager.saving') }}...</span>
<span v-if="currentTabSaveStatus === 'success'" class="save-status success">✅ {{ t('fileManager.saveSuccess') }}</span>
<span v-if="currentTabSaveStatus === 'error'" class="save-status error">❌ {{ t('fileManager.saveError') }}: {{ currentTabSaveError }}</span>
<button @click="handleSaveRequest" :disabled="currentTabIsSaving || currentTabIsLoading || !!currentTabLoadingError || !activeTab || !currentTabIsModified" class="save-btn">
{{ t('fileManager.actions.save') }}
</button>
<!-- 关闭/最小化按钮已移除 -->
</div>
</div>
<!-- 如果没有活动标签页,显示简化头部 -->
<div v-else class="editor-header editor-header-placeholder">
<span>{{ t('fileManager.noOpenFile') }}</span>
<!-- 动作区域留空或只显示通用按钮 -->
</div>
<!-- 3. 编辑器内容区域 -->
<div class="editor-content-area">
<div v-if="currentTabIsLoading" class="editor-loading">{{ t('fileManager.loadingFile') }}</div>
<div v-else-if="currentTabLoadingError" class="editor-error">{{ currentTabLoadingError }}</div>
<MonacoEditor
v-else-if="activeTab"
ref="monacoEditorRef"
:key="activeTab.id"
v-model="localEditorContent"
:language="currentTabLanguage"
theme="vs-dark"
class="editor-instance"
@request-save="handleSaveRequest"
/>
<div v-else class="editor-placeholder">{{ t('fileManager.selectFileToEdit') }}</div>
</div>
</div>
</template>
<style scoped>
/* 样式与 FileEditorOverlay 类似,但移除 backdrop 和 popup 结构 */
.file-editor-container {
width: 100%;
height: 100%; /* 填充父级 Pane */
background-color: #2d2d2d; /* 编辑器背景 */
display: flex;
flex-direction: column;
color: #f0f0f0;
overflow: hidden; /* 重要:防止内容溢出 */
}
/* 标签栏区域 */
/* FileEditorTabs 组件自带样式 */
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background-color: #333;
border-bottom: 1px solid #555;
font-size: 0.9em;
flex-shrink: 0;
}
.editor-header-placeholder {
justify-content: flex-start; /* 左对齐提示文本 */
color: #888;
}
.modified-indicator {
color: #ffeb3b;
margin-left: 4px;
font-weight: bold;
}
/* 编辑器内容区域 */
.editor-content-area {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.editor-loading, .editor-error, .editor-placeholder {
padding: 2rem;
text-align: center;
font-size: 1.1em;
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
color: #888;
}
.editor-error { color: #ff8a8a; }
.editor-placeholder { color: #666; }
.editor-actions {
display: flex;
align-items: center;
gap: 0.8rem; /* 稍微减小间距以容纳下拉菜单 */
}
.save-btn {
background-color: #4CAF50;
color: white;
border: none;
padding: 0.4rem 0.8rem;
cursor: pointer;
border-radius: 3px;
font-size: 0.9em;
}
.save-btn:disabled { background-color: #aaa; cursor: not-allowed; }
.save-btn:hover:not(:disabled) { background-color: #45a049; }
.save-status {
font-size: 0.9em;
padding: 0.2rem 0.5rem;
border-radius: 3px;
white-space: nowrap;
}
.save-status.saving { color: #888; }
.save-status.success { color: #4CAF50; background-color: #e8f5e9; }
.save-status.error { color: #f44336; background-color: #ffebee; }
.editor-instance {
flex-grow: 1;
min-height: 0;
}
</style>
<style scoped> /* Add new styles below existing scoped styles */
.encoding-select-wrapper {
display: inline-block; /* 让 wrapper 包裹内容 */
vertical-align: middle; /* 垂直居中对齐 */
}
.encoding-select {
background-color: #444;
color: #f0f0f0;
border: 1px solid #666;
padding: 0.3rem 0.5rem; /* 恢复内边距 */
border-radius: 3px;
font-size: 0.85em;
cursor: pointer;
outline: none;
/* width: auto; */ /* JS will control width via style property */
/* 移除 flex-shrink */
/* 确保没有其他样式覆盖,例如内联样式或更高优先级的选择器 */
}
.encoding-select:hover {
background-color: #555;
}
.encoding-select:focus {
border-color: #888;
}
.encoding-select-placeholder {
font-size: 0.85em;
color: #888;
padding: 0.3rem 0.5rem;
display: inline-block;
min-width: 80px; /* 与 select 大致对齐 */
text-align: center;
}
</style>