新增: 多工程管理(编辑/删除确认/记忆选中/自动扫描子仓库)
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git地址) - 工程删除二次确认:防误删 - 记住上次选中工程:localStorage 持久化 - 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
This commit is contained in:
@@ -768,6 +768,88 @@ pub async fn get_module_file_diff(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 扫描项目绑定目录下的子仓库(含 .git 的直接子目录),自动创建工程记录。
|
||||
/// 返回新创建的工程数量。幂等:已存在的路径不重复创建。
|
||||
#[tauri::command]
|
||||
pub async fn scan_project_modules(
|
||||
state: State<'_, AppState>,
|
||||
project_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let project_id = project_id.trim().to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
// 取项目记录拿 path
|
||||
let project = state
|
||||
.projects
|
||||
.get_by_id(&project_id)
|
||||
.await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("项目 {project_id} 不存在"))?;
|
||||
let root_path = project
|
||||
.path
|
||||
.as_ref()
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
.ok_or_else(|| "项目未绑定目录".to_string())?;
|
||||
let root = PathBuf::from(root_path);
|
||||
// 取已有工程路径集(避免重复创建)
|
||||
let existing = state
|
||||
.project_modules
|
||||
.list_by_project(&project_id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
let existing_paths: std::collections::HashSet<String> = existing
|
||||
.iter()
|
||||
.map(|m| m.path.to_lowercase().replace("\\", "/"))
|
||||
.collect();
|
||||
// 扫描一级子目录(含 .git)
|
||||
let mut new_count = 0i64;
|
||||
let entries = std::fs::read_dir(&root)
|
||||
.map_err(|e| format!("读取项目目录失败: {e}"))?;
|
||||
for entry in entries.flatten() {
|
||||
let ft = match entry.file_type() { Ok(t) => t, Err(_) => continue };
|
||||
if !ft.is_dir() { continue; }
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// 跳过隐藏目录和噪音目录
|
||||
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "__pycache__" || name == "dist" {
|
||||
continue;
|
||||
}
|
||||
let child_path = entry.path();
|
||||
// 必须含 .git(独立仓库)
|
||||
if !child_path.join(".git").exists() { continue; }
|
||||
let child_path_str = child_path.to_string_lossy().replace("\\", "/");
|
||||
if existing_paths.contains(&child_path_str.to_lowercase()) { continue; }
|
||||
// 获取远程地址(失败忽略)
|
||||
let git_url = std::process::Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(&child_path)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let now_str = now_millis();
|
||||
let record = ProjectModuleRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.clone(),
|
||||
name: name.clone(),
|
||||
path: child_path.to_string_lossy().to_string(),
|
||||
git_url,
|
||||
stack: None,
|
||||
auto_detected: true,
|
||||
sort_order: (existing.len() + new_count as usize) as i32,
|
||||
created_at: now_str.clone(),
|
||||
updated_at: now_str,
|
||||
};
|
||||
if let Err(e) = state.project_modules.insert(record).await {
|
||||
tracing::warn!("自动探测工程 {} 失败: {}", child_path_str, e);
|
||||
continue;
|
||||
}
|
||||
new_count += 1;
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
|
||||
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
|
||||
fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
||||
|
||||
@@ -324,6 +324,7 @@ pub fn run() {
|
||||
commands::module::update_project_module,
|
||||
commands::module::remove_project_module,
|
||||
commands::module::list_project_modules,
|
||||
commands::module::scan_project_modules,
|
||||
commands::module::get_module_git_status,
|
||||
// 工程文件浏览(Batch 10):文件树 + 单文件预览
|
||||
commands::module::get_module_file_tree,
|
||||
|
||||
@@ -87,6 +87,11 @@ export const moduleApi = {
|
||||
return invoke('add_project_module', { input })
|
||||
},
|
||||
|
||||
/** 扫描项目绑定目录下的子仓库,自动创建工程记录(幂等)。返回新创建数量。 */
|
||||
scanProjectModules(projectId: string): Promise<number> {
|
||||
return invoke('scan_project_modules', { projectId })
|
||||
},
|
||||
|
||||
/** 列出项目全部工程(按 sort_order ASC)。 */
|
||||
listProjectModules(projectId: string): Promise<ProjectModuleRecord[]> {
|
||||
return invoke('list_project_modules', { projectId })
|
||||
@@ -128,6 +133,22 @@ export const moduleApi = {
|
||||
return invoke('get_module_file_diff', { moduleId, filePath })
|
||||
},
|
||||
|
||||
/** 更新工程记录。 */
|
||||
updateProjectModule(input: {
|
||||
id: string
|
||||
name?: string
|
||||
path?: string
|
||||
gitUrl?: string | null
|
||||
stack?: string | null
|
||||
}): Promise<ProjectModuleRecord> {
|
||||
return invoke('update_project_module', { input })
|
||||
},
|
||||
|
||||
/** 删除工程记录。 */
|
||||
removeProjectModule(moduleId: string): Promise<void> {
|
||||
return invoke('remove_project_module', { id: moduleId })
|
||||
},
|
||||
|
||||
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
|
||||
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
|
||||
commits: { hash: string; subject: string; timestamp: number }[]
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<span class="module-dropdown-name">{{ m.name }}</span>
|
||||
<span v-if="m.path" class="module-dropdown-path" :title="m.path">{{ m.path }}</span>
|
||||
</div>
|
||||
<div class="module-dropdown-divider"></div>
|
||||
<div class="module-dropdown-item module-dropdown-action" @click="onScanSubmodules">
|
||||
<span class="module-dropdown-name">🔍 {{ $t('fileExplorer.scanSubmodules') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +45,13 @@
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-ghost btn-sm" type="button" title="添加工程" @click="showAddModule = true">
|
||||
<button class="btn btn-ghost btn-sm" type="button" title="编辑当前工程" @click="openEditModule">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" title="删除当前工程" @click="confirmRemoveModule">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" title="添加工程" @click="openAddModule">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" :disabled="refreshing" @click="refresh">
|
||||
@@ -115,26 +125,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增工程弹窗 -->
|
||||
<div v-if="showAddModule" class="modal-overlay" @click.self="showAddModule = false">
|
||||
<!-- 新增/编辑工程弹窗 -->
|
||||
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
|
||||
<div class="modal-box modal-box--sm">
|
||||
<h3 class="modal-title">{{ $t('fileExplorer.addModule') }}</h3>
|
||||
<h3 class="modal-title">{{ editingModule ? $t('fileExplorer.editModule') : $t('fileExplorer.addModule') }}</h3>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleName') }}</label>
|
||||
<input v-model="newModuleName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
|
||||
<input v-model="moduleFormName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.modulePath') }}</label>
|
||||
<input v-model="newModulePath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
||||
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('common.optional') }}</span></label>
|
||||
<input v-model="newModuleGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
||||
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showAddModule = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="addingModule || !newModuleName.trim() || !newModulePath.trim()" @click="onAddModule">
|
||||
{{ addingModule ? $t('fileExplorer.adding') : $t('fileExplorer.addModule') }}
|
||||
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
|
||||
{{ savingModule ? $t('common.loading') : (editingModule ? $t('common.save') : $t('fileExplorer.addModule')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除工程确认弹窗 -->
|
||||
<div v-if="showRemoveConfirm" class="modal-overlay" @click.self="showRemoveConfirm = false">
|
||||
<div class="modal-box modal-box--sm">
|
||||
<h3 class="modal-title">{{ $t('fileExplorer.removeModule') }}</h3>
|
||||
<p style="margin: 12px 0; color: var(--df-text-secondary);">
|
||||
{{ $t('fileExplorer.removeConfirm', { name: currentModule?.name ?? '' }) }}
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showRemoveConfirm = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-danger" :disabled="removingModule" @click="onRemoveModule">
|
||||
{{ removingModule ? $t('common.loading') : $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +179,7 @@
|
||||
* 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules),
|
||||
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
|
||||
*/
|
||||
import { ref, computed, watch, reactive } from 'vue'
|
||||
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
|
||||
import FileTree from './FileTree.vue'
|
||||
@@ -172,12 +198,17 @@ const currentModuleId = ref<string>('')
|
||||
const refreshing = ref(false)
|
||||
const moduleDropdownOpen = ref(false)
|
||||
|
||||
// 新增工程弹窗
|
||||
const showAddModule = ref(false)
|
||||
const addingModule = ref(false)
|
||||
const newModuleName = ref('')
|
||||
const newModulePath = ref('')
|
||||
const newModuleGitUrl = ref('')
|
||||
// 工程 CRUD 弹窗
|
||||
const showModuleModal = ref(false)
|
||||
const editingModule = ref<ProjectModuleRecord | null>(null)
|
||||
const savingModule = ref(false)
|
||||
const moduleFormName = ref('')
|
||||
const moduleFormPath = ref('')
|
||||
const moduleFormGitUrl = ref('')
|
||||
|
||||
// 删除确认
|
||||
const showRemoveConfirm = ref(false)
|
||||
const removingModule = ref(false)
|
||||
|
||||
// 树状态(顶层持有,递归子树共享 props)。
|
||||
const expandedPaths = reactive(new Set<string>())
|
||||
@@ -204,14 +235,20 @@ const breadcrumbSegments = computed(() => {
|
||||
return segs
|
||||
})
|
||||
|
||||
/** 拉工程列表并初始化选中。 */
|
||||
/** 拉工程列表并初始化选中(记忆上次选中)。 */
|
||||
async function loadModules() {
|
||||
try {
|
||||
const list = await moduleApi.listProjectModules(props.projectId)
|
||||
modules.value = list
|
||||
if (list.length > 0 && !list.some((m) => m.id === currentModuleId.value)) {
|
||||
if (list.length > 0) {
|
||||
const lastSelected = localStorage.getItem('df-module-' + props.projectId)
|
||||
const target = list.find(m => m.id === lastSelected)
|
||||
if (target) {
|
||||
currentModuleId.value = target.id
|
||||
} else if (!list.some((m) => m.id === currentModuleId.value)) {
|
||||
currentModuleId.value = list[0].id
|
||||
}
|
||||
}
|
||||
resetTreeState()
|
||||
} catch (e) {
|
||||
console.error('[FileExplorer] 加载工程列表失败', e)
|
||||
@@ -230,6 +267,7 @@ function resetTreeState() {
|
||||
/** 自定义下拉选择工程。 */
|
||||
function onModuleSelect(id: string) {
|
||||
currentModuleId.value = id
|
||||
localStorage.setItem('df-module-' + props.projectId, id)
|
||||
moduleDropdownOpen.value = false
|
||||
resetTreeState()
|
||||
}
|
||||
@@ -322,31 +360,94 @@ function onChangeFileSelect(path: string) {
|
||||
watch(() => props.projectId, loadModules, { immediate: true })
|
||||
|
||||
// 点击外部关闭工程下拉
|
||||
document.addEventListener('click', () => { moduleDropdownOpen.value = false })
|
||||
function closeDropdown() { moduleDropdownOpen.value = false }
|
||||
onMounted(() => document.addEventListener('click', closeDropdown))
|
||||
onUnmounted(() => document.removeEventListener('click', closeDropdown))
|
||||
|
||||
/** 提交新增工程。 */
|
||||
async function onAddModule() {
|
||||
if (addingModule.value) return
|
||||
addingModule.value = true
|
||||
// 自动扫描子仓库
|
||||
async function onScanSubmodules() {
|
||||
moduleDropdownOpen.value = false
|
||||
try {
|
||||
const n = await moduleApi.scanProjectModules(props.projectId)
|
||||
await loadModules()
|
||||
if (n > 0) {
|
||||
console.log('[FileExplorer] 扫描到新工程:', n)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[FileExplorer] 扫描子仓库失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开新增工程弹窗。 */
|
||||
function openAddModule() {
|
||||
editingModule.value = null
|
||||
moduleFormName.value = ''
|
||||
moduleFormPath.value = ''
|
||||
moduleFormGitUrl.value = ''
|
||||
showModuleModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑当前工程弹窗。 */
|
||||
function openEditModule() {
|
||||
if (!currentModule.value) return
|
||||
editingModule.value = currentModule.value
|
||||
moduleFormName.value = currentModule.value.name
|
||||
moduleFormPath.value = currentModule.value.path
|
||||
moduleFormGitUrl.value = currentModule.value.git_url ?? ''
|
||||
showModuleModal.value = true
|
||||
}
|
||||
|
||||
/** 弹出删除当前工程确认。 */
|
||||
function confirmRemoveModule() {
|
||||
if (!currentModule.value) return
|
||||
showRemoveConfirm.value = true
|
||||
}
|
||||
|
||||
/** 保存工程(新增/编辑)。 */
|
||||
async function onSaveModule() {
|
||||
if (savingModule.value) return
|
||||
savingModule.value = true
|
||||
try {
|
||||
if (editingModule.value) {
|
||||
await moduleApi.updateProjectModule({
|
||||
id: editingModule.value.id,
|
||||
name: moduleFormName.value.trim(),
|
||||
path: moduleFormPath.value.trim(),
|
||||
gitUrl: moduleFormGitUrl.value.trim() || null,
|
||||
})
|
||||
} else {
|
||||
await moduleApi.addProjectModule({
|
||||
projectId: props.projectId,
|
||||
name: newModuleName.value.trim(),
|
||||
path: newModulePath.value.trim(),
|
||||
gitUrl: newModuleGitUrl.value.trim() || null,
|
||||
name: moduleFormName.value.trim(),
|
||||
path: moduleFormPath.value.trim(),
|
||||
gitUrl: moduleFormGitUrl.value.trim() || null,
|
||||
})
|
||||
showAddModule.value = false
|
||||
newModuleName.value = ''
|
||||
newModulePath.value = ''
|
||||
newModuleGitUrl.value = ''
|
||||
// 刷新工程列表
|
||||
}
|
||||
showModuleModal.value = false
|
||||
await loadModules()
|
||||
} catch (e) {
|
||||
console.error('[FileExplorer] 添加工程失败:', e)
|
||||
console.error('[FileExplorer] 保存工程失败:', e)
|
||||
} finally {
|
||||
addingModule.value = false
|
||||
savingModule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除当前工程。 */
|
||||
async function onRemoveModule() {
|
||||
if (removingModule.value || !currentModule.value) return
|
||||
removingModule.value = true
|
||||
try {
|
||||
await moduleApi.removeProjectModule(currentModule.value.id)
|
||||
showRemoveConfirm.value = false
|
||||
currentModuleId.value = ''
|
||||
await loadModules()
|
||||
} catch (e) {
|
||||
console.error('[FileExplorer] 删除工程失败:', e)
|
||||
} finally {
|
||||
removingModule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -436,6 +537,15 @@ async function onAddModule() {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-left: 2px solid var(--df-accent);
|
||||
}
|
||||
.module-dropdown-divider {
|
||||
height: 0.5px;
|
||||
background: var(--df-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.module-dropdown-action .module-dropdown-name {
|
||||
color: var(--df-accent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.module-dropdown-name { font-size: 12px; color: var(--df-text); }
|
||||
.module-dropdown-path {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -29,5 +29,9 @@ export default {
|
||||
modulePathPlaceholder: 'e.g. D:/projects/my-app/frontend',
|
||||
moduleGitUrl: 'Git URL',
|
||||
moduleGitUrlPlaceholder: 'https://github.com/… (optional)',
|
||||
editModule: 'Edit Module',
|
||||
removeModule: 'Remove Module',
|
||||
removeConfirm: 'Are you sure to remove module "{name}"? This action cannot be undone.',
|
||||
scanSubmodules: 'Scan sub-repositories',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ export default {
|
||||
modulePathPlaceholder: '例如 D:/projects/my-app/frontend',
|
||||
moduleGitUrl: 'Git 地址',
|
||||
moduleGitUrlPlaceholder: 'https://github.com/…(可选)',
|
||||
editModule: '编辑工程',
|
||||
removeModule: '删除工程',
|
||||
removeConfirm: '确定删除工程「{name}」吗?此操作不可撤销。',
|
||||
scanSubmodules: '扫描子仓库',
|
||||
// Git Changes tab
|
||||
loadMore: '加载更多',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user