Files
CloudSSH/frontend/src/code-editor.ts
T
TanXin 90b570569d release: 发布 v1.14.1 自动换行功能版本 (#114)
## 发布说明

**v1.14.1 自动换行功能版本**

### 版本要点

- **SFTP 编辑器自动换行**(关联 #113):编辑器页脚新增「自动换行」开关,基于 CodeMirror 6
`EditorView.lineWrapping` 经 `Compartment`
动态切换(不重建编辑器、不丢状态);触屏/窄屏默认开启、桌面端默认关闭由用户手动开启(检测口径与 16px 字号媒体查询一致);偏好持久化于
`localStorage`(`cloudssh_editor_wrap`),隐私模式回退设备默认值;移动端页脚三按钮空间优化;i18n 双语
+ 桌面/移动双视口 e2e 守护默认值、切换与持久化。

### 提交列表

| 提交 | 说明 |
| ------ | ------ |
| ddb7925 | feat: SFTP 编辑器新增自动换行开关(移动端默认开启)(#113) |
| 1479a5f | release: 发布 v1.14.1 自动换行功能版本(版本号 + CHANGELOG + 构建产物) |

### 关联 Issue

- #113(sftp文件编辑移动端自动换行)——保持 open,待用户后续手动关闭

### 验证结果

- ✅ `pnpm run verify` 全绿:typecheck(worker + frontend)/ test 58 文件 698 用例
/ build:frontend(单 JS + CSS 内联)/ test:e2e 62 用例
- ✅ test 环境 CI 部署成功
- ✅ 新增 e2e:桌面默认不换行可开启并持久化、移动端默认换行可关闭并持久化
2026-08-30 10:50:58 +08:00

500 lines
16 KiB
TypeScript
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.
/**
* SFTP 在线编辑器:CodeMirror 6 模态封装。
*
* 设计要点:
* - 主题通过 CSS 变量(--bg-surface/--text/--accent 等)绑定应用 Theme V3,
* 语法高亮使用 @lezer/highlight 的 classHighlighter,配色在 style.css 中
* 以主题变量定义,天然跟随亮/暗/自定义主题。
* - 编辑器内文本一律为 \n 换行、无 BOM 的规范化内容;BOM/EOL 的还原
* 由 editor-content.ts 在保存编码时完成。
* - 保存经 onSave 回调交由 SFTPPanel 执行(mtime 冲突检测 + 现有上传覆盖通道),
* 本组件只负责 dirty 跟踪与保存触发(Ctrl+S / 按钮)。
* - 对话框结构与样式遵循 app-dialog / auth-challenge-dialog 的既有设计令牌。
*/
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import {
StreamLanguage,
bracketMatching,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { yaml } from '@codemirror/lang-yaml';
import { classHighlighter } from '@lezer/highlight';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import {
EditorView,
drawSelection,
highlightActiveLine,
keymap,
lineNumbers,
rectangularSelection,
crosshairCursor,
} from '@codemirror/view';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { lua } from '@codemirror/legacy-modes/mode/lua';
import { properties } from '@codemirror/legacy-modes/mode/properties';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { t, translateDocument } from './i18n';
import type { DecodedContent } from './editor-content';
import { confirmAction, notify } from './ui-feedback';
export interface RemoteEditorOptions {
filename: string;
path: string;
content: DecodedContent;
/** 只读模式(非 UTF-8 编码文件无法安全回写) */
readOnly: boolean;
/** 只读原因提示文案(已翻译) */
notice?: string;
/** 执行保存(冲突检测 + 上传),返回 true 表示保存成功 */
onSave: () => Promise<boolean>;
/** 编辑器关闭(无论是否保存) */
onClose: () => void;
}
export interface RemoteEditorHandle {
isDirty(): boolean;
getContent(): string;
markSaved(): void;
focus(): void;
close(): void;
}
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '12.5px',
backgroundColor: 'var(--bg-terminal)',
color: 'var(--text)',
},
'.cm-scroller': {
fontFamily: 'var(--font-code)',
lineHeight: '1.55',
overflow: 'auto',
},
'.cm-content': {
caretColor: 'var(--accent)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--accent)',
borderLeftWidth: '2px',
},
'&.cm-focused > .cm-scroller > .cm-layer.cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor: 'var(--accent-bg)',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'var(--bg)',
color: 'var(--text-dim)',
border: 'none',
borderRight: '1px solid var(--border)',
},
'.cm-activeLine': {
backgroundColor: 'var(--bg-elevated)',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--bg-elevated)',
color: 'var(--text-muted)',
},
'.cm-foldGutter span': {
color: 'var(--text-dim)',
},
'.cm-foldGutter span:hover': {
color: 'var(--accent)',
},
'.cm-selectionMatch': {
backgroundColor: 'var(--accent-bg)',
},
'.cm-searchMatch': {
backgroundColor: 'var(--accent-bg)',
outline: '1px solid var(--accent-secondary)',
},
'.cm-searchMatch-selected': {
backgroundColor: 'var(--accent-secondary-light)',
},
'.cm-panels': {
backgroundColor: 'var(--bg-elevated)',
color: 'var(--text)',
border: '1px solid var(--border)',
},
'.cm-panel input, .cm-panel button': {
background: 'var(--bg-surface)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--control-radius, 6px)',
},
'.cm-tooltip': {
backgroundColor: 'var(--bg-elevated)',
color: 'var(--text)',
border: '1px solid var(--border)',
},
});
/** 语法高亮配色:全部映射主题变量,跟随亮/暗/自定义主题 */
const highlightTheme = syntaxHighlighting(classHighlighter, { fallback: true });
function languageExtensionFor(filename: string): Extension {
const name = filename.toLowerCase();
const dotIndex = name.lastIndexOf('.');
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
switch (ext) {
case 'js':
case 'mjs':
case 'cjs':
case 'jsx':
return javascript();
case 'ts':
case 'mts':
case 'cts':
return javascript({ typescript: true });
case 'tsx':
return javascript({ typescript: true, jsx: true });
case 'json':
return json();
case 'yml':
case 'yaml':
return yaml();
case 'py':
return python();
case 'md':
case 'markdown':
return markdown();
case 'html':
case 'htm':
return html();
case 'css':
case 'less':
case 'scss':
return css();
case 'sh':
case 'bash':
case 'zsh':
case 'ksh':
return StreamLanguage.define(shell);
case 'conf':
case 'ini':
case 'cfg':
case 'cnf':
case 'env':
case 'properties':
return StreamLanguage.define(properties);
case 'toml':
return StreamLanguage.define(toml);
case 'lua':
return StreamLanguage.define(lua);
default:
if (name === 'dockerfile') return StreamLanguage.define(dockerFile);
if (name.endsWith('.service') || name.endsWith('.socket') || name.endsWith('.timer')) {
return StreamLanguage.define(properties);
}
return [];
}
}
/** 自动换行偏好存储键(沿用 cloudssh_ 前缀约定) */
const WRAP_STORAGE_KEY = 'cloudssh_editor_wrap';
/**
* 自动换行默认值(Issue #113):触屏/窄屏默认开启——长行(URL、证书串等)
* 在移动端横向拖动查看是明确痛点;桌面端默认关闭,由用户手动开启。
* 检测口径与编辑器 16px 字号媒体查询保持一致(pointer: coarse 或 ≤520px)。
*/
function detectDefaultWrap(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(pointer: coarse)').matches || window.innerWidth <= 520;
}
function readStoredWrapPreference(): boolean | null {
try {
const stored = window.localStorage.getItem(WRAP_STORAGE_KEY);
if (stored === 'on') return true;
if (stored === 'off') return false;
} catch {
// 隐私模式等存储不可用场景回退设备默认值
}
return null;
}
function storeWrapPreference(enabled: boolean): void {
try {
window.localStorage.setItem(WRAP_STORAGE_KEY, enabled ? 'on' : 'off');
} catch {
// 同上,忽略持久化失败,仅影响下次打开的默认值
}
}
class RemoteEditor implements RemoteEditorHandle {
private readonly dialog: HTMLDialogElement;
private readonly view: EditorView;
private readonly options: RemoteEditorOptions;
private readonly statusEl: HTMLElement;
private readonly saveButton: HTMLButtonElement;
private readonly wrapCompartment = new Compartment();
private wrapButton: HTMLButtonElement | null = null;
private wrapEnabled = false;
private baseline: string;
private saving = false;
private closed = false;
constructor(options: RemoteEditorOptions) {
this.options = options;
this.baseline = options.content.text;
this.wrapEnabled = readStoredWrapPreference() ?? detectDefaultWrap();
this.dialog = this.buildDialog();
document.body.appendChild(this.dialog);
const bodyEl = this.dialog.querySelector('.remote-editor__body') as HTMLElement;
const titleEl = this.dialog.querySelector('.remote-editor__title') as HTMLElement;
const pathEl = this.dialog.querySelector('.remote-editor__path') as HTMLElement;
const noticeEl = this.dialog.querySelector('.remote-editor__notice') as HTMLElement;
this.statusEl = this.dialog.querySelector('.remote-editor__status') as HTMLElement;
const closeButton = this.dialog.querySelector('.remote-editor__close') as HTMLButtonElement;
const discardButton = this.dialog.querySelector(
'.remote-editor__button--cancel'
) as HTMLButtonElement;
this.saveButton = this.dialog.querySelector(
'.remote-editor__button--save'
) as HTMLButtonElement;
this.wrapButton = this.dialog.querySelector(
'.remote-editor__button--wrap'
) as HTMLButtonElement;
titleEl.textContent = options.filename;
pathEl.textContent = options.path;
if (options.readOnly) {
noticeEl.textContent = options.notice ?? '';
noticeEl.hidden = !noticeEl.textContent;
}
discardButton.textContent = t('common.close');
this.saveButton.textContent = t('common.save');
this.saveButton.disabled = options.readOnly;
translateDocument(this.dialog);
closeButton.addEventListener('click', () => void this.requestClose());
discardButton.addEventListener('click', () => void this.requestClose());
this.saveButton.addEventListener('click', () => void this.requestSave());
this.wrapButton.addEventListener('click', () => this.toggleWrap());
this.dialog.addEventListener('cancel', (event) => {
event.preventDefault();
void this.requestClose();
});
this.dialog.addEventListener('close', () => this.destroy());
this.view = new EditorView({
parent: bodyEl,
state: this.buildState(),
});
this.updateStatus();
this.syncWrapButton();
this.dialog.showModal();
this.view.focus();
}
private buildDialog(): HTMLDialogElement {
const dialog = document.createElement('dialog');
dialog.className = 'remote-editor';
dialog.setAttribute('aria-label', t('sftp.editorTitle', { name: this.options.filename }));
// 静态模板,动态内容(文件名/路径)一律 textContent 写入
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
dialog.innerHTML = `
<div class="remote-editor__panel">
<div class="remote-editor__header">
<span class="material-symbols-outlined remote-editor__icon" aria-hidden="true">edit_note</span>
<div class="remote-editor__titles">
<h2 class="remote-editor__title"></h2>
<span class="remote-editor__path"></span>
</div>
<button type="button" class="remote-editor__close" data-i18n-title="common.close" aria-label="关闭">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
<div class="remote-editor__notice" role="note" hidden></div>
<div class="remote-editor__body"></div>
<div class="remote-editor__footer">
<span class="remote-editor__status"></span>
<div class="remote-editor__actions">
<button type="button" class="remote-editor__button remote-editor__button--wrap" data-i18n="sftp.editorWrap" data-i18n-title="sftp.editorWrapTitle" aria-pressed="false"></button>
<button type="button" class="remote-editor__button remote-editor__button--cancel"></button>
<button type="button" class="remote-editor__button remote-editor__button--save"></button>
</div>
</div>
</div>
`;
return dialog;
}
private buildState(): EditorState {
const extensions: Extension[] = [
lineNumbers(),
foldGutter(),
history(),
drawSelection(),
highlightActiveLine(),
bracketMatching(),
indentOnInput(),
search({ top: true }),
highlightSelectionMatches(),
rectangularSelection(),
crosshairCursor(),
highlightTheme,
editorTheme,
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => {
void this.requestSave();
return true;
},
},
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
indentWithTab,
]),
languageExtensionFor(this.options.filename),
this.wrapCompartment.of(this.wrapEnabled ? EditorView.lineWrapping : []),
EditorView.updateListener.of((update) => {
if (update.docChanged) this.updateStatus();
}),
];
if (this.options.readOnly) {
extensions.push(EditorState.readOnly.of(true), EditorView.editable.of(false));
}
return EditorState.create({ doc: this.options.content.text, extensions });
}
isDirty(): boolean {
return this.view.state.doc.toString() !== this.baseline;
}
getContent(): string {
return this.view.state.doc.toString();
}
markSaved(): void {
this.baseline = this.view.state.doc.toString();
this.updateStatus();
}
focus(): void {
if (!this.closed) this.view.focus();
}
/** 切换自动换行(Issue #113):Compartment 动态重配置 + 偏好持久化 */
private toggleWrap(): void {
this.wrapEnabled = !this.wrapEnabled;
this.view.dispatch({
effects: this.wrapCompartment.reconfigure(
this.wrapEnabled ? EditorView.lineWrapping : []
),
});
storeWrapPreference(this.wrapEnabled);
this.syncWrapButton();
this.view.focus();
}
private syncWrapButton(): void {
this.wrapButton?.setAttribute('aria-pressed', String(this.wrapEnabled));
}
/** 立即关闭(不经确认):面板会话拆除等强制场景使用 */
close(): void {
this.destroy();
}
private updateStatus(): void {
if (this.closed) return;
const encoding =
this.options.content.encoding === 'gb18030'
? t('sftp.editorEncodingGb18030')
: t('sftp.editorEncodingUtf8');
const eol = this.options.content.eol === '\r\n' ? 'CRLF' : 'LF';
const state = this.isDirty() ? t('sftp.editorStatusDirty') : t('sftp.editorStatusSaved');
this.statusEl.textContent = `${encoding} · ${eol} · ${state}`;
if (!this.options.readOnly && !this.saving) {
this.saveButton.disabled = !this.isDirty();
}
}
private async requestSave(): Promise<void> {
if (this.closed || this.saving || this.options.readOnly) return;
if (!this.isDirty()) {
notify(t('sftp.editorNoChanges'));
return;
}
this.saving = true;
this.saveButton.disabled = true;
const savedLabel = this.saveButton.textContent;
this.saveButton.textContent = t('sftp.editorSaving');
try {
const ok = await this.options.onSave();
if (ok) this.baseline = this.view.state.doc.toString();
} finally {
this.saving = false;
this.saveButton.disabled = this.options.readOnly || !this.isDirty();
this.saveButton.textContent = savedLabel ?? t('common.save');
this.updateStatus();
}
}
/** 关闭前确认:有未保存修改时经用户确认才丢弃 */
private async requestClose(): Promise<void> {
if (this.closed) return;
if (this.saving) {
notify(t('sftp.editorSavingWait'));
return;
}
if (this.isDirty() && !this.options.readOnly) {
const discard = await confirmAction({
title: t('sftp.editorDirtyTitle'),
message: t('sftp.editorDirtyMessage', { name: this.options.filename }),
confirmText: t('sftp.editorDiscard'),
cancelText: t('common.cancel'),
variant: 'danger',
});
if (!discard) {
this.focus();
return;
}
}
this.destroy();
}
private destroy(): void {
if (this.closed) return;
this.closed = true;
this.view.destroy();
this.dialog.close();
this.dialog.remove();
this.options.onClose();
}
}
export function openRemoteEditor(options: RemoteEditorOptions): RemoteEditorHandle {
return new RemoteEditor(options);
}