Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,16 @@ export const EditorArea: React.FC<EditorAreaProps> = ({

const handleTabClose = useCallback((groupId: EditorGroupId) => async (tabId: string) => {
if (onTabCloseWithDirtyCheck) {
const shouldClose = await onTabCloseWithDirtyCheck(tabId, groupId);
if (!shouldClose) return;
await onTabCloseWithDirtyCheck(tabId, groupId);
return;
}
closeTab(tabId, groupId);
}, [closeTab, onTabCloseWithDirtyCheck]);

const handleCloseAllTabs = useCallback((groupId: EditorGroupId) => async () => {
if (onTabCloseAllWithDirtyCheck) {
const shouldClose = await onTabCloseAllWithDirtyCheck(groupId);
if (!shouldClose) return;
await onTabCloseAllWithDirtyCheck(groupId);
return;
}
closeAllTabs(groupId);
}, [closeAllTabs, onTabCloseAllWithDirtyCheck]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { EditorGroupId, PanelContent, CreateTabEventDetail } from '../types
import { TAB_EVENTS } from '../types';
import { useI18n } from '@/infrastructure/i18n';
import { drainPendingTabs } from '@/shared/services/pendingTabQueue';
import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService';
interface UseTabLifecycleOptions {
/** App mode / target canvas */
mode?: 'agent' | 'project' | 'git';
Expand Down Expand Up @@ -45,6 +46,12 @@ interface UseTabLifecycleReturn {
export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLifecycleReturn => {
const { mode = 'agent' } = options;
const { t } = useI18n('components');
const canvasStoreApi =
mode === 'project'
? useProjectCanvasStore
: mode === 'git'
? useGitCanvasStore
: useAgentCanvasStore;

const {
addTab,
Expand All @@ -55,8 +62,6 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif
updateTabContent,
closeTab,
closeAllTabs,
primaryGroup,
secondaryGroup,
activeGroupId,
layout,
setSplitMode,
Expand Down Expand Up @@ -123,18 +128,21 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif
* Dirty check before closing a tab.
*/
const handleCloseWithDirtyCheck = useCallback(async (tabId: string, groupId: EditorGroupId): Promise<boolean> => {
const group = groupId === 'primary' ? primaryGroup : secondaryGroup;
const { primaryGroup: latestPrimaryGroup, secondaryGroup: latestSecondaryGroup } = canvasStoreApi.getState();
const group = groupId === 'primary' ? latestPrimaryGroup : latestSecondaryGroup;
const tab = group.tabs.find(t => t.id === tabId);

if (!tab) {
return true;
}

if (tab.isDirty) {
// Show confirmation and ensure correct return handling
const result = window.confirm(
t('tabs.confirmCloseWithDirty', { title: tab.title })
);
const result = await confirmDialog({
title: t('tabs.unsaved'),
message: t('tabs.confirmCloseWithDirty', { title: tab.title }),
type: 'warning',
confirmDanger: true,
});

if (!result) {
return false;
Expand All @@ -143,33 +151,37 @@ export const useTabLifecycle = (options: UseTabLifecycleOptions = {}): UseTabLif

closeTab(tabId, groupId);
return true;
}, [primaryGroup, secondaryGroup, closeTab, t]);
}, [canvasStoreApi, closeTab, t]);

/**
* Dirty check before closing all tabs.
*/
const handleCloseAllWithDirtyCheck = useCallback(async (groupId: EditorGroupId): Promise<boolean> => {
const group = groupId === 'primary' ? primaryGroup : secondaryGroup;
const { primaryGroup: latestPrimaryGroup, secondaryGroup: latestSecondaryGroup } = canvasStoreApi.getState();
const group = groupId === 'primary' ? latestPrimaryGroup : latestSecondaryGroup;
const dirtyTabs = group.tabs.filter(t => t.isDirty);

if (dirtyTabs.length === 0) {
closeAllTabs(groupId);
return true;
}

// Show confirmation with list of dirty files
const fileList = dirtyTabs.map(t => ` - ${t.title}`).join('\n');
const result = window.confirm(
t('tabs.confirmCloseAllWithDirty', { count: dirtyTabs.length, fileList })
);
const result = await confirmDialog({
title: t('tabs.unsaved'),
message: t('tabs.confirmCloseAllWithDirty', { count: dirtyTabs.length, fileList }),
type: 'warning',
confirmDanger: true,
preview: fileList,
});

if (!result) {
return false;
}

closeAllTabs(groupId);
return true;
}, [primaryGroup, secondaryGroup, closeAllTabs, t]);
}, [canvasStoreApi, closeAllTabs, t]);

/**
* Listen for left-panel terminal close events to sync right-panel tabs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ export const MissionControl: React.FC<MissionControlProps> = ({
// Handle tab close
const handleTabClose = useCallback(async (tabId: string, groupId: EditorGroupId) => {
if (handleCloseWithDirtyCheck) {
const shouldClose = await handleCloseWithDirtyCheck(tabId, groupId);
if (!shouldClose) return;
await handleCloseWithDirtyCheck(tabId, groupId);
return;
}
closeTab(tabId, groupId);
}, [closeTab, handleCloseWithDirtyCheck]);
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/en-US/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@
"loadingFile": "Loading file...",
"placeholder": "Start writing Markdown...",
"source": "Source",
"markdown": "Markdown",
"preview": "Preview",
"viewModeLabel": "Markdown source and preview mode",
"viewModeLabel": "Markdown and preview mode",
"notice": {
"sourcePreviewFallback": "This document contains HTML fragments that are not safely editable in visual mode. Edit in source mode or switch to preview."
}
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-CN/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@
"loadingFile": "正在加载文件...",
"placeholder": "开始编写 Markdown 内容...",
"source": "源码",
"markdown": "Markdown",
"preview": "预览",
"viewModeLabel": "Markdown 源码与预览模式",
"viewModeLabel": "Markdown 与预览模式",
"notice": {
"sourcePreviewFallback": "该文档包含无法在可视化模式中安全编辑的 HTML 片段。请在源码模式中编辑,或切换到预览查看效果。"
}
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/tools/editor/components/MarkdownEditor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
}
}

.bitfun-markdown-editor__unsafe-toolbar {
.bitfun-markdown-editor__mode-toolbar {
display: flex;
justify-content: flex-end;
padding: $size-gap-3 $size-gap-4;
Expand All @@ -50,7 +50,7 @@
flex-shrink: 0;
}

.bitfun-markdown-editor__unsafe-toggle {
.bitfun-markdown-editor__mode-toggle {
display: inline-flex;
align-items: center;
gap: $size-gap-2;
Expand Down
32 changes: 28 additions & 4 deletions src/web-ui/src/tools/editor/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
const { isLight } = useTheme();
const [content, setContent] = useState<string>(initialContent);
const [hasChanges, setHasChanges] = useState(false);
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview');
const [unsafeViewMode, setUnsafeViewMode] = useState<'source' | 'preview'>('source');
const [loading, setLoading] = useState(!!filePath);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -162,6 +163,7 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
}, []);

useEffect(() => {
setViewMode('preview');
setUnsafeViewMode('source');
}, [filePath, initialContent]);

Expand Down Expand Up @@ -630,16 +632,16 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
</div>
</div>
)}
<div className="bitfun-markdown-editor__unsafe-toolbar">
<div className="bitfun-markdown-editor__unsafe-toggle" role="tablist" aria-label={t('editor.markdownEditor.viewModeLabel')}>
<div className="bitfun-markdown-editor__mode-toolbar">
<div className="bitfun-markdown-editor__mode-toggle" role="tablist" aria-label={t('editor.markdownEditor.viewModeLabel')}>
<Button
type="button"
size="small"
variant={unsafeViewMode === 'source' ? 'primary' : 'secondary'}
onClick={() => setUnsafeViewMode('source')}
aria-pressed={unsafeViewMode === 'source'}
>
{t('editor.markdownEditor.source')}
{t('editor.markdownEditor.markdown')}
</Button>
<Button
type="button"
Expand Down Expand Up @@ -718,13 +720,35 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
</div>
</div>
)}
<div className="bitfun-markdown-editor__mode-toolbar">
<div className="bitfun-markdown-editor__mode-toggle" role="tablist" aria-label={t('editor.markdownEditor.viewModeLabel')}>
<Button
type="button"
size="small"
variant={viewMode === 'preview' ? 'primary' : 'secondary'}
onClick={() => setViewMode('preview')}
aria-pressed={viewMode === 'preview'}
>
{t('editor.markdownEditor.preview')}
</Button>
<Button
type="button"
size="small"
variant={viewMode === 'markdown' ? 'primary' : 'secondary'}
onClick={() => setViewMode('markdown')}
aria-pressed={viewMode === 'markdown'}
>
{t('editor.markdownEditor.markdown')}
</Button>
</div>
</div>
<MEditor
ref={editorRef}
value={content}
onChange={handleContentChange}
onSave={handleSave}
onDirtyChange={handleDirtyChange}
mode="ir"
mode={viewMode === 'preview' ? 'ir' : 'edit'}
theme={isLight ? 'light' : 'dark'}
height="100%"
width="100%"
Expand Down
39 changes: 32 additions & 7 deletions src/web-ui/src/tools/editor/meditor/components/MEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
const placeholder = placeholderProp ?? t('editor.meditor.placeholder')
const containerRef = useRef<HTMLDivElement>(null)
const textareaTargetIdRef = useRef(`markdown-textarea-${++markdownTextareaTargetCounter}`)
const initialEditorValue = controlledValue ?? defaultValue
const savedValueRef = useRef(initialEditorValue)
const currentValueRef = useRef(initialEditorValue)

const {
value,
Expand All @@ -82,6 +85,10 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
? (readonly ? 'preview' : 'split')
: mode

useEffect(() => {
currentValueRef.current = value
}, [value])

useEffect(() => {
if (effectiveMode === 'ir' || effectiveMode === 'preview') {
return
Expand Down Expand Up @@ -115,9 +122,11 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>

useEffect(() => {
if (controlledValue !== undefined && controlledValue !== value) {
setValue(controlledValue)
currentValueRef.current = controlledValue
editorInstance.setValue(controlledValue)
onDirtyChange?.(controlledValue !== savedValueRef.current)
}
}, [controlledValue, value, setValue])
}, [controlledValue, editorInstance, onDirtyChange, value])

useEffect(() => {
if (initialMode) {
Expand All @@ -131,6 +140,12 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
}
}, [initialTheme, setTheme])

const handleEditorChange = useCallback((nextValue: string) => {
currentValueRef.current = nextValue
setValue(nextValue)
onDirtyChange?.(nextValue !== savedValueRef.current)
}, [onDirtyChange, setValue])

useImperativeHandle(ref, () => ({
...editorInstance,
scrollToLine: (line: number, highlight?: boolean) => {
Expand Down Expand Up @@ -172,19 +187,29 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
if (effectiveMode === 'ir' && tiptapEditorRef.current) {
tiptapEditorRef.current.markSaved()
}
savedValueRef.current = currentValueRef.current
onDirtyChange?.(false)
},
setInitialContent: (content: string) => {
if (effectiveMode === 'ir' && tiptapEditorRef.current) {
tiptapEditorRef.current.setInitialContent(content)
currentValueRef.current = content
savedValueRef.current = content
onDirtyChange?.(false)
return
}
currentValueRef.current = content
savedValueRef.current = content
editorInstance.setValue(content)
onDirtyChange?.(false)
},
get isDirty() {
if (effectiveMode === 'ir' && tiptapEditorRef.current) {
return tiptapEditorRef.current.isDirty
}
return false
return currentValueRef.current !== savedValueRef.current
}
}), [editorInstance, effectiveMode, textareaRef])
}), [editorInstance, effectiveMode, onDirtyChange, textareaRef])

const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
Expand Down Expand Up @@ -249,7 +274,7 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
<EditArea
ref={textareaRef}
value={value}
onChange={setValue}
onChange={handleEditorChange}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
Expand All @@ -265,7 +290,7 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
<EditArea
ref={textareaRef}
value={value}
onChange={setValue}
onChange={handleEditorChange}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
Expand All @@ -284,7 +309,7 @@ export const MEditor = forwardRef<EditorInstance, MEditorProps>((props, ref) =>
<TiptapEditor
ref={tiptapEditorRef}
value={value}
onChange={setValue}
onChange={handleEditorChange}
onFocus={onFocus}
onBlur={onBlur}
onDirtyChange={onDirtyChange}
Expand Down
Loading
Loading