feat(workspace): 新增工作区文件内容编辑功能,支持 Markdown 和 TXT 文件的保存与预览
This commit is contained in:
parent
981fd3170d
commit
596eeeaaac
@ -19,6 +19,9 @@ from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
|
||||
from yuxi.utils.paths import WORKSPACE_DIR_NAME
|
||||
|
||||
|
||||
EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"}
|
||||
|
||||
|
||||
def _workspace_root(user: User) -> Path:
|
||||
try:
|
||||
root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME
|
||||
@ -143,6 +146,37 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def write_workspace_file_content(*, path: str, content: str, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="当前路径是目录")
|
||||
if target.suffix.lower() not in EDITABLE_WORKSPACE_SUFFIXES:
|
||||
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||
|
||||
raw_content = await asyncio.to_thread(target.read_bytes)
|
||||
preview_type, supported, _message = _detect_preview_type(path, raw_content)
|
||||
if preview_type not in {"markdown", "text"} or not supported:
|
||||
raise HTTPException(status_code=400, detail="当前文件类型不支持编辑")
|
||||
try:
|
||||
raw_content.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="当前文件不是 UTF-8 文本") from exc
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(target.write_text, content, encoding="utf-8")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": _normalize_workspace_path(path).as_posix(),
|
||||
"entry": _entry_for_path(root, target),
|
||||
}
|
||||
|
||||
|
||||
async def delete_workspace_path(*, path: str, current_user: User) -> dict:
|
||||
root = _workspace_root(current_user)
|
||||
target = _resolve_workspace_path(current_user, path)
|
||||
|
||||
@ -11,6 +11,7 @@ from yuxi.services.workspace_service import (
|
||||
list_workspace_tree,
|
||||
read_workspace_file_content,
|
||||
upload_workspace_file,
|
||||
write_workspace_file_content,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
@ -22,6 +23,11 @@ class CreateWorkspaceDirectoryRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class UpdateWorkspaceFileContentRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
@workspace.get("/tree", response_model=dict)
|
||||
async def get_workspace_tree(
|
||||
path: str = Query("/", description="工作区目录路径"),
|
||||
@ -38,6 +44,18 @@ async def get_workspace_file(
|
||||
return await read_workspace_file_content(path=path, current_user=current_user)
|
||||
|
||||
|
||||
@workspace.put("/file", response_model=dict)
|
||||
async def update_workspace_file(
|
||||
payload: UpdateWorkspaceFileContentRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
return await write_workspace_file_content(
|
||||
path=payload.path,
|
||||
content=payload.content,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@workspace.delete("/file", response_model=dict)
|
||||
async def delete_workspace_file_route(
|
||||
path: str = Query(..., description="工作区文件或目录路径"),
|
||||
|
||||
@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from yuxi.agents.backends.sandbox import paths as workspace_paths
|
||||
from yuxi.services import workspace_service as svc
|
||||
|
||||
@ -38,3 +41,76 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey
|
||||
|
||||
assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace"
|
||||
assert agents_file.read_text(encoding="utf-8") == "保留已有内容"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "note.md"
|
||||
target.write_text("旧内容", encoding="utf-8")
|
||||
|
||||
result = await svc.write_workspace_file_content(path="/note.md", content="# 新内容", current_user=user)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["path"] == "/note.md"
|
||||
assert result["entry"]["path"] == "/note.md"
|
||||
assert target.read_text(encoding="utf-8") == "# 新内容"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "note.txt"
|
||||
target.write_text("old", encoding="utf-8")
|
||||
|
||||
await svc.write_workspace_file_content(path="/note.txt", content="new", current_user=user)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
root = svc._workspace_root(user)
|
||||
target = root / "script.py"
|
||||
target.write_text("print('hello')", encoding="utf-8")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.write_workspace_file_content(path="/script.py", content="print('bye')", current_user=user)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert target.read_text(encoding="utf-8") == "print('hello')"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_rejects_directory_and_missing_file(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
user = SimpleNamespace(id="user-1")
|
||||
svc._workspace_root(user)
|
||||
|
||||
with pytest.raises(HTTPException) as directory_error:
|
||||
await svc.write_workspace_file_content(path="/agents/", content="x", current_user=user)
|
||||
with pytest.raises(HTTPException) as missing_error:
|
||||
await svc.write_workspace_file_content(path="/missing.md", content="x", current_user=user)
|
||||
|
||||
assert directory_error.value.status_code == 400
|
||||
assert missing_error.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await svc.write_workspace_file_content(
|
||||
path="/../outside.md",
|
||||
content="x",
|
||||
current_user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
### 0.6.2 开发记录
|
||||
|
||||
<!-- 0.6.2 的内容请放在这里 -->
|
||||
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
||||
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
|
||||
- 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。
|
||||
- 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `<a-tag bordered=false size=small>` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。
|
||||
- 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { apiDelete, apiGet, apiPost } from './base'
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from './base'
|
||||
|
||||
const buildQuery = (params) => {
|
||||
const query = new URLSearchParams()
|
||||
@ -20,6 +20,10 @@ export const getWorkspaceFileContent = (path) => {
|
||||
return apiGet(`/api/workspace/file?${query}`)
|
||||
}
|
||||
|
||||
export const saveWorkspaceFileContent = (path, content) => {
|
||||
return apiPut('/api/workspace/file', { path, content })
|
||||
}
|
||||
|
||||
export const deleteWorkspacePath = (path) => {
|
||||
const query = buildQuery({ path })
|
||||
return apiDelete(`/api/workspace/file?${query}`)
|
||||
|
||||
@ -9,6 +9,42 @@
|
||||
<span class="file-path-title">{{ filePath }}</span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<div v-if="canEdit" class="preview-mode-switch">
|
||||
<button
|
||||
class="preview-mode-btn"
|
||||
:class="{ active: editMode === 'preview' }"
|
||||
@click="editMode = 'preview'"
|
||||
title="预览"
|
||||
>
|
||||
<Eye :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="preview-mode-btn"
|
||||
:class="{ active: editMode === 'edit' }"
|
||||
@click="editMode = 'edit'"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canEdit && editMode === 'edit'"
|
||||
class="modal-action-btn"
|
||||
:disabled="saving || !draftChanged"
|
||||
@click="requestSave"
|
||||
title="保存"
|
||||
>
|
||||
<Save :size="18" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && editMode === 'edit'"
|
||||
class="modal-action-btn"
|
||||
:disabled="saving"
|
||||
@click="cancelEdit"
|
||||
title="取消编辑"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<div v-if="isHtmlFile" class="preview-mode-switch">
|
||||
<button
|
||||
class="preview-mode-btn"
|
||||
@ -50,7 +86,15 @@
|
||||
</div>
|
||||
|
||||
<div class="file-content" :class="contentClass">
|
||||
<template v-if="file?.previewType === 'image' && file?.previewUrl">
|
||||
<template v-if="canEdit && editMode === 'edit'">
|
||||
<textarea
|
||||
v-model="draftContent"
|
||||
class="file-edit-textarea"
|
||||
:disabled="saving"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="file?.previewType === 'image' && file?.previewUrl">
|
||||
<div class="image-preview-wrapper">
|
||||
<img :src="file.previewUrl" :alt="filePath" class="image-preview" />
|
||||
</div>
|
||||
@ -190,13 +234,20 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { Code2, Download, Globe, Maximize2, X } from 'lucide-vue-next'
|
||||
import { Code2, Download, Eye, Globe, Maximize2, Pencil, Save, X } from 'lucide-vue-next'
|
||||
import { MdPreview } from 'md-editor-v3'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import 'md-editor-v3/lib/preview.css'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
|
||||
import { getCodeLanguageByPath, isHtmlPreview, isMarkdownPreview } from '@/utils/file_preview'
|
||||
import {
|
||||
getCodeLanguageByPath,
|
||||
getPreviewFileExtension,
|
||||
isHtmlPreview,
|
||||
isMarkdownPreview
|
||||
} from '@/utils/file_preview'
|
||||
|
||||
const EDITABLE_EXTENSIONS = new Set(['.md', '.markdown', '.mdx', '.txt'])
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
@ -227,6 +278,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
containerClass: {
|
||||
type: [String, Array, Object],
|
||||
default: ''
|
||||
@ -237,15 +296,26 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['close', 'download'])
|
||||
const emit = defineEmits(['close', 'download', 'save'])
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
const htmlPreviewMode = ref('render')
|
||||
const editMode = ref('preview')
|
||||
const draftContent = ref('')
|
||||
const fullscreenPreviewVisible = ref(false)
|
||||
const htmlPreviewRenderKey = ref(0)
|
||||
|
||||
const isMarkdown = computed(() => isMarkdownPreview(props.filePath, props.file?.previewType))
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
props.editable &&
|
||||
props.file?.supported !== false &&
|
||||
typeof props.file?.content === 'string' &&
|
||||
EDITABLE_EXTENSIONS.has(getPreviewFileExtension(props.filePath))
|
||||
)
|
||||
const savedContent = computed(() => formatContent(props.file?.content))
|
||||
const draftChanged = computed(() => draftContent.value !== savedContent.value)
|
||||
const isHtmlFile = computed(
|
||||
() =>
|
||||
props.file?.previewType === 'text' &&
|
||||
@ -293,6 +363,20 @@ const formatContent = (content) => {
|
||||
return String(content)
|
||||
}
|
||||
|
||||
const syncDraftContent = () => {
|
||||
draftContent.value = savedContent.value
|
||||
editMode.value = 'preview'
|
||||
}
|
||||
|
||||
const requestSave = () => {
|
||||
if (!canEdit.value || props.saving) return
|
||||
emit('save', draftContent.value)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
syncDraftContent()
|
||||
}
|
||||
|
||||
const openFullscreenPreview = () => {
|
||||
if (!props.file) return
|
||||
fullscreenPreviewVisible.value = true
|
||||
@ -309,6 +393,8 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch([() => props.filePath, () => props.file?.content, canEdit], syncDraftContent, { immediate: true })
|
||||
|
||||
watch([() => props.filePath, () => props.file?.previewType, () => props.file?.content], () => {
|
||||
if (isHtmlFile.value) {
|
||||
htmlPreviewRenderKey.value += 1
|
||||
@ -381,8 +467,8 @@ onUnmounted(() => {
|
||||
|
||||
.modal-action-btn,
|
||||
.preview-mode-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -401,6 +487,18 @@ onUnmounted(() => {
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.modal-action-btn:disabled,
|
||||
.preview-mode-btn:disabled {
|
||||
color: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-action-btn:disabled:hover,
|
||||
.preview-mode-btn:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--gray-300);
|
||||
}
|
||||
|
||||
.preview-mode-btn.active {
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-900);
|
||||
@ -436,6 +534,25 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.file-edit-textarea {
|
||||
width: 100%;
|
||||
min-height: calc(80vh - 40px);
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: var(--gray-0);
|
||||
color: var(--gray-1000);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.file-edit-textarea:disabled {
|
||||
color: var(--gray-600);
|
||||
background: var(--gray-25);
|
||||
}
|
||||
|
||||
.file-content pre,
|
||||
.file-content-pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
|
||||
@ -8,9 +8,12 @@
|
||||
:show-close="true"
|
||||
:show-fullscreen="true"
|
||||
:full-height="true"
|
||||
:editable="editable"
|
||||
:saving="saving"
|
||||
container-class="workspace-preview-container"
|
||||
content-class="workspace-preview-content"
|
||||
@close="$emit('close')"
|
||||
@save="$emit('save', $event)"
|
||||
/>
|
||||
<div v-else-if="loading" class="preview-state">
|
||||
<a-spin />
|
||||
@ -19,7 +22,7 @@
|
||||
<div v-else class="preview-empty">
|
||||
<FileSearch :size="28" />
|
||||
<h3>选择文件以预览</h3>
|
||||
<p>支持 Markdown、文本、代码、图片与 PDF 的只读预览。</p>
|
||||
<p>支持 Markdown、TXT 编辑,其他格式保持只读预览。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@ -31,10 +34,12 @@ import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||
defineProps({
|
||||
file: { type: Object, default: null },
|
||||
filePath: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false }
|
||||
loading: { type: Boolean, default: false },
|
||||
editable: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
defineEmits(['close', 'save'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@ -79,7 +79,10 @@
|
||||
:file="previewFile"
|
||||
:file-path="selectedEntry?.path || ''"
|
||||
:loading="loadingPreview"
|
||||
:editable="true"
|
||||
:saving="savingPreviewFile"
|
||||
@close="closePreview"
|
||||
@save="handleSavePreviewFile"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -123,7 +126,10 @@
|
||||
:showClose="true"
|
||||
:showDownload="false"
|
||||
:showFullscreen="true"
|
||||
:editable="activeSourceKey === 'personal'"
|
||||
:saving="savingPreviewFile"
|
||||
@close="closePreview"
|
||||
@save="handleSavePreviewFile"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
@ -145,6 +151,7 @@ import {
|
||||
downloadWorkspaceFile,
|
||||
getWorkspaceFileContent,
|
||||
getWorkspaceTree,
|
||||
saveWorkspaceFileContent,
|
||||
uploadWorkspaceFile
|
||||
} from '@/apis/workspace_api'
|
||||
|
||||
@ -159,6 +166,7 @@ const previewObjectUrl = ref('')
|
||||
const previewModalVisible = ref(false)
|
||||
const loadingTree = ref(false)
|
||||
const loadingPreview = ref(false)
|
||||
const savingPreviewFile = ref(false)
|
||||
const loadingDatabases = ref(false)
|
||||
const databases = ref([])
|
||||
const selectedDatabase = ref(null)
|
||||
@ -339,6 +347,29 @@ const closePreview = () => {
|
||||
revokePreviewObjectUrl()
|
||||
}
|
||||
|
||||
const handleSavePreviewFile = async (content) => {
|
||||
if (!selectedEntry.value?.path || savingPreviewFile.value) return
|
||||
|
||||
savingPreviewFile.value = true
|
||||
try {
|
||||
const response = await saveWorkspaceFileContent(selectedEntry.value.path, content)
|
||||
if (response.entry) {
|
||||
selectedEntry.value = response.entry
|
||||
}
|
||||
previewFile.value = {
|
||||
...previewFile.value,
|
||||
content
|
||||
}
|
||||
await loadWorkspaceEntries(currentPath.value)
|
||||
message.success('文件保存成功')
|
||||
} catch (error) {
|
||||
console.warn('保存工作区文件失败:', error)
|
||||
message.error(error?.message || '文件保存失败')
|
||||
} finally {
|
||||
savingPreviewFile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDirectoryModal = () => {
|
||||
if (activeSourceKey.value !== 'personal') return
|
||||
newDirectoryName.value = ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user