diff --git a/src/App.vue b/src/App.vue index 0c49075..a8eb942 100644 --- a/src/App.vue +++ b/src/App.vue @@ -83,7 +83,7 @@ provide('activatedView', activatedView) @@ -98,8 +98,7 @@ provide('activatedView', activatedView) diff --git a/src/components/LanguagePop/LanguagePop.vue b/src/components/LanguagePop/LanguagePop.vue index cd887c4..17272dd 100644 --- a/src/components/LanguagePop/LanguagePop.vue +++ b/src/components/LanguagePop/LanguagePop.vue @@ -33,7 +33,10 @@ function updateOpen(open: boolean) { } function languageLabel(text: string) { - return text === 'Other' ? t('language_pop.other') : text + const normalized = text.trim() + if (normalized.toLowerCase() === 'unknown' || normalized.toLowerCase() === 'unknow') + return t('app.common.unknown') + return normalized === 'Other' ? t('language_pop.other') : normalized } @@ -45,6 +48,10 @@ function languageLabel(text: string) { - - + + {{ t('language_pop.loading') }} - + {{ t('language_pop.failed') }} - - {{ t('language_pop.count', { count: languagesGroup.length }) }} + + {{ t('language_pop.count', { count: languagesGroup.length }) }} - + - + - - + + {{ languageLabel(languageItem.text) }} - + {{ languageItem.percentage }}% - + {{ t('language_pop.more', { count: hiddenLanguageCount }) }} @@ -104,14 +143,10 @@ function languageLabel(text: string) { diff --git a/src/components/WindowHeader.vue b/src/components/WindowHeader.vue index 672e2a7..66172c9 100644 --- a/src/components/WindowHeader.vue +++ b/src/components/WindowHeader.vue @@ -19,19 +19,52 @@ function closeWindow() { - CodeNest - - - - + CodeNest + + + + - - + + - - + + @@ -39,27 +72,15 @@ function closeWindow() { diff --git a/src/components/ui/UiCombobox.vue b/src/components/ui/UiCombobox.vue index 2930f32..8aae520 100644 --- a/src/components/ui/UiCombobox.vue +++ b/src/components/ui/UiCombobox.vue @@ -114,22 +114,19 @@ function handleBlur(event: FocusEvent) { .ui-combobox-anchor { @apply h-30px rounded-md border px-9px; @apply inline-flex items-center justify-between gap-7px; - @apply border-$ui-input bg-$ui-control-background color-$ui-foreground; - @apply transition duration-120 ease-out; - border-style: solid; - box-shadow: var(--shadow-control); + @apply border-solid border-$ui-input bg-$ui-control-background color-$ui-foreground; + @apply shadow-$shadow-control transition duration-120 ease-out; &:hover { border-color: color-mix(in srgb, var(--ui-input), var(--ui-foreground) 18%); } &:focus-within { - border-color: var(--ui-ring); - box-shadow: var(--shadow-focus); + @apply border-$ui-ring shadow-$shadow-focus; } &.invalid { - border-color: var(--red-5); + @apply border-$red-5; } } @@ -138,7 +135,7 @@ function handleBlur(event: FocusEvent) { @apply text-11px color-$ui-foreground; &::placeholder { - color: var(--ui-muted-foreground); + @apply color-$ui-muted-foreground; } &:disabled { @@ -149,7 +146,7 @@ function handleBlur(event: FocusEvent) { .ui-combobox-trigger { @apply h-full w-16px shrink-0 border-0 bg-transparent p-0; @apply inline-flex items-center justify-center cursor-pointer text-12px opacity-65; - color: var(--ui-muted-foreground); + @apply color-$ui-muted-foreground; &:disabled { @apply cursor-not-allowed; @@ -158,13 +155,11 @@ function handleBlur(event: FocusEvent) { .ui-combobox-content { @apply z-50 rounded-md border p-1 outline-none; - @apply border-$ui-border bg-$ui-popover-background color-$ui-foreground; - @apply backdrop-blur-8px backdrop-saturate-140; - border-style: solid; - box-shadow: var(--shadow-popup); + @apply border-solid border-$ui-border bg-$ui-popover-background color-$ui-foreground; + @apply shadow-$shadow-popup backdrop-blur-8px backdrop-saturate-140; &[data-state="open"] { - animation: combobox-enter 120ms cubic-bezier(0.16, 1, 0.3, 1); + @apply animate-[combobox-enter_120ms_cubic-bezier(0.16,1,0.3,1)]; } } @@ -178,8 +173,7 @@ function handleBlur(event: FocusEvent) { @apply flex items-center cursor-pointer select-none text-12px; &[data-highlighted] { - @apply color-$ui-accent-foreground; - background-color: var(--ui-option-hover-background); + @apply bg-$ui-option-hover-background color-$ui-accent-foreground; } &[data-disabled] { @@ -198,7 +192,7 @@ function handleBlur(event: FocusEvent) { @keyframes combobox-enter { from { - opacity: 0; + @apply opacity-0; transform: translateY(-3px) scale(0.985); } } diff --git a/src/components/ui/UiDialog.vue b/src/components/ui/UiDialog.vue index 6937434..aef29ea 100644 --- a/src/components/ui/UiDialog.vue +++ b/src/components/ui/UiDialog.vue @@ -39,15 +39,32 @@ const contentStyle = computed(() => ({ @update:open="value => emit('update:open', value)" > - - - + + + {{ title }} - + {{ description }} @@ -55,7 +72,12 @@ const contentStyle = computed(() => ({ - + @@ -65,41 +87,23 @@ const contentStyle = computed(() => ({ diff --git a/src/components/ui/UiSegmentedControl.vue b/src/components/ui/UiSegmentedControl.vue index aeb8b2c..aa8bf7b 100644 --- a/src/components/ui/UiSegmentedControl.vue +++ b/src/components/ui/UiSegmentedControl.vue @@ -35,11 +35,22 @@ function updateValue(value: unknown) { :model-value="modelValue" @update:model-value="updateValue" > - + diff --git a/src/components/ui/UiToaster.vue b/src/components/ui/UiToaster.vue index 616f174..1a525f8 100644 --- a/src/components/ui/UiToaster.vue +++ b/src/components/ui/UiToaster.vue @@ -108,24 +108,21 @@ function toastIcon(tone: UiToastTone) { diff --git a/src/views/HomeView/components/HomeEmptyState.vue b/src/views/HomeView/components/HomeEmptyState.vue new file mode 100644 index 0000000..ef4b3a5 --- /dev/null +++ b/src/views/HomeView/components/HomeEmptyState.vue @@ -0,0 +1,15 @@ + + + + + {{ t('app.home.empty.title') }} + {{ t('app.home.empty.desc') }} + + diff --git a/src/views/HomeView/components/HomeFilterBar.vue b/src/views/HomeView/components/HomeFilterBar.vue new file mode 100644 index 0000000..cbdf5c3 --- /dev/null +++ b/src/views/HomeView/components/HomeFilterBar.vue @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + {{ t('app.home.clear_count', { count: activeFilterCount }) }} + + + + + + + diff --git a/src/views/HomeView/components/HomeHeader.vue b/src/views/HomeView/components/HomeHeader.vue new file mode 100644 index 0000000..5723c20 --- /dev/null +++ b/src/views/HomeView/components/HomeHeader.vue @@ -0,0 +1,114 @@ + + + + + + + + {{ t('app.home.title') }} + {{ total }} + + + + + + + + + + + {{ syncing ? t('app.home.syncing') : t('app.home.sync') }} + + + + {{ t('app.home.add_project') }} + + + + + + diff --git a/src/views/HomeView/LanguageFilterSelect.vue b/src/views/HomeView/components/LanguageFilterSelect.vue similarity index 67% rename from src/views/HomeView/LanguageFilterSelect.vue rename to src/views/HomeView/components/LanguageFilterSelect.vue index 5423461..7b7c964 100644 --- a/src/views/HomeView/LanguageFilterSelect.vue +++ b/src/views/HomeView/components/LanguageFilterSelect.vue @@ -62,22 +62,25 @@ function colorDotStyle(color?: string) { :aria-label="triggerLabel" > - + - + {{ selectedOption?.label || placeholder }} - + {{ selectedOption.count }} - + @@ -98,18 +101,21 @@ function colorDotStyle(color?: string) { :value="option.value" > - + - + - {{ option.label }} + {{ option.label }} - + {{ option.count }} @@ -119,36 +125,3 @@ function colorDotStyle(color?: string) { - - diff --git a/src/views/HomeView/components/ProjectCard.vue b/src/views/HomeView/components/ProjectCard.vue new file mode 100644 index 0000000..99d0fac --- /dev/null +++ b/src/views/HomeView/components/ProjectCard.vue @@ -0,0 +1,149 @@ + + + + emit('menuSelect', project, action)" + > + + + + + + + {{ displayPath }} + + + + + + emit('showLanguage', p, event)" /> + + + + + + + + diff --git a/src/views/HomeView/components/ProjectGrid.vue b/src/views/HomeView/components/ProjectGrid.vue new file mode 100644 index 0000000..05d4a35 --- /dev/null +++ b/src/views/HomeView/components/ProjectGrid.vue @@ -0,0 +1,75 @@ + + + + + + emit('itemRef', id, element)" + @actions-ref="(id, element) => emit('actionsRef', id, element)" + @open="emit('open', project)" + @container-keydown="(p, event) => emit('containerKeydown', p, event)" + @actions-keydown="(p, event) => emit('actionsKeydown', p, event)" + @show-language="(p, event) => emit('showLanguage', p, event)" + @open-source="emit('openSource', project)" + @open-explorer="emit('openExplorer', project)" + @open-terminal="emit('openTerminal', project)" + @copy-path="emit('copyPath', project)" + @menu-select="(p, action) => emit('menuSelect', p, action)" + /> + + + + diff --git a/src/views/HomeView/components/ProjectList.vue b/src/views/HomeView/components/ProjectList.vue new file mode 100644 index 0000000..e171340 --- /dev/null +++ b/src/views/HomeView/components/ProjectList.vue @@ -0,0 +1,131 @@ + + + + + {{ t('app.home.table.project') }} + {{ t('app.home.table.language') }} + {{ t('app.home.table.editor') }} + {{ t('app.home.table.license') }} + {{ t('app.home.table.last_opened') }} + {{ t('app.home.table.actions') }} + + + + emit('itemRef', id, element)" + @actions-ref="(id, element) => emit('actionsRef', id, element)" + @open="emit('open', project)" + @container-keydown="(p, event) => emit('containerKeydown', p, event)" + @actions-keydown="(p, event) => emit('actionsKeydown', p, event)" + @show-language="(p, event) => emit('showLanguage', p, event)" + @open-source="emit('openSource', project)" + @open-explorer="emit('openExplorer', project)" + @open-terminal="emit('openTerminal', project)" + @copy-path="emit('copyPath', project)" + @menu-select="(p, action) => emit('menuSelect', p, action)" + /> + + + + + diff --git a/src/views/HomeView/components/ProjectListRow.vue b/src/views/HomeView/components/ProjectListRow.vue new file mode 100644 index 0000000..e1215c7 --- /dev/null +++ b/src/views/HomeView/components/ProjectListRow.vue @@ -0,0 +1,159 @@ + + + + emit('menuSelect', project, action)" + > + + + + + {{ displayPath }} + + + + + emit('showLanguage', p, event)" + /> + + + + {{ formatLastOpened(project, locale, t) }} + + emit('actionsRef', id, element)" + @open="emit('open', project)" + @explorer="emit('openExplorer', project)" + @terminal="emit('openTerminal', project)" + @copy="emit('copyPath', project)" + @menu-select="(p, action) => emit('menuSelect', p, action)" + @keydown="(p, event) => emit('actionsKeydown', p, event)" + /> + + + + + diff --git a/src/views/HomeView/components/project/ProjectActions.vue b/src/views/HomeView/components/project/ProjectActions.vue new file mode 100644 index 0000000..24ef59e --- /dev/null +++ b/src/views/HomeView/components/project/ProjectActions.vue @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + emit('menuSelect', project, action)" + > + + + + + + + + diff --git a/src/views/HomeView/components/project/ProjectEditorChip.vue b/src/views/HomeView/components/project/ProjectEditorChip.vue new file mode 100644 index 0000000..8c55400 --- /dev/null +++ b/src/views/HomeView/components/project/ProjectEditorChip.vue @@ -0,0 +1,33 @@ + + + + + + {{ editorLabel(editor, t) }} + + + + diff --git a/src/views/HomeView/components/project/ProjectMeta.vue b/src/views/HomeView/components/project/ProjectMeta.vue new file mode 100644 index 0000000..3adc193 --- /dev/null +++ b/src/views/HomeView/components/project/ProjectMeta.vue @@ -0,0 +1,59 @@ + + + + + + {{ languageLabel }} + + + {{ shortLicense(project.license, t) }} + + {{ t('app.license.none') }} + diff --git a/src/views/HomeView/components/project/ProjectSource.vue b/src/views/HomeView/components/project/ProjectSource.vue new file mode 100644 index 0000000..7e397b9 --- /dev/null +++ b/src/views/HomeView/components/project/ProjectSource.vue @@ -0,0 +1,81 @@ + + + + + {{ projectSourcePrefix(project, t) }} + {{ projectSourceName(project) }} + + + + {{ projectSourcePrefix(project, t) }} + {{ projectSourceName(project) }} + + + + diff --git a/src/views/HomeView/components/project/ProjectTitle.vue b/src/views/HomeView/components/project/ProjectTitle.vue new file mode 100644 index 0000000..bbacf9d --- /dev/null +++ b/src/views/HomeView/components/project/ProjectTitle.vue @@ -0,0 +1,104 @@ + + + + + + + {{ project.group }}/{{ project.name }} + + + + {{ project.group }}/{{ project.name }} + + + + {{ kindLabel(project.kind, t) }} + + + {{ t('app.home.remote.badge') }} + + + {{ t('app.home.filters.temporary') }} + + + + + + diff --git a/src/views/HomeView/composables/useProjectActions.ts b/src/views/HomeView/composables/useProjectActions.ts new file mode 100644 index 0000000..8188f43 --- /dev/null +++ b/src/views/HomeView/composables/useProjectActions.ts @@ -0,0 +1,424 @@ +import { computed, ref } from 'vue' +import { useI18n } from 'vue-i18n' + +import { showPop as showLanguagePop } from '~/components/LanguagePop/LanguagePopProvider' +import { showRemoveDialog } from '~/components/RemoveProjectDialog' +import { showToast } from '~/components/ui/toast' +import { SettingPageEnum, ViewEnum } from '~/constants/appEnums' +import type { CodeEditorEnum } from '~/constants/codeEditor' +import { isCodeEditor, isVscodeHistoryScannerEditor } from '~/constants/codeEditor' +import type { LocalProject } from '~/constants/localProject' +import { + DEFAULT_EDITOR_ACTION_PREFIX, + DEFAULT_EDITOR_AUTO_ACTION, + OPEN_WITH_ACTION_PREFIX, +} from '~/constants/projectActions' +import type { ProjectScannerImportResult } from '~/services/projectScannerService' +import { addNewProjectsFromScanner, scannerBusy } from '~/services/projectScannerService' +import { useEditorLangGroupsStore } from '~/stores/editorLangGroupsStore' +import { useProjectsStore } from '~/stores/projectsStore' +import { useSettingsStore } from '~/stores/settingsStore' +import { formatActionError } from '~/utils/error' +import { projectSourceExternalUrl } from '~/utils/projectSource' +import { + initializeNewProjectState, + initializeUpdateProjectState, +} from '~/views/ProjectEditorView' +import { activatedPage } from '~/views/SettingsView' + +import { + MIN_SYNC_BUSY_MS, + SCAN_RESULT_TOAST_THRESHOLD_MS, +} from '../constants' + +interface ProjectActionsOpenState { + clearProjectOpening: (projectId: number) => void + clearProjectFromMaps: (projectId: number) => void + clearTerminalOpening: (projectId: number) => void + finishProjectOpening: (projectId: number) => void + finishTerminalOpening: (projectId: number) => void + isProjectOpening: (project: LocalProject) => boolean + isTerminalOpening: (project: LocalProject) => boolean + markProjectOpening: (projectId: number) => void + markTerminalOpening: (projectId: number) => void +} + +interface UseProjectActionsOptions { + deleteProjectRefs: (projectId: number) => void + navigateFromHome: (view: ViewEnum) => void + openState: ProjectActionsOpenState +} + +export function useProjectActions(options: UseProjectActionsOptions) { + const projectsStore = useProjectsStore() + const settingsStore = useSettingsStore() + const editorLangGroupsStore = useEditorLangGroupsStore() + const { t } = useI18n() + + const manualSyncing = ref(false) + const syncing = computed(() => manualSyncing.value || scannerBusy.value) + const copyFeedback = ref>({}) + const copyFeedbackTimers = new Map() + + const { + clearProjectFromMaps: clearOpenStateFromMaps, + clearProjectOpening, + clearTerminalOpening, + finishProjectOpening, + finishTerminalOpening, + isProjectOpening, + isTerminalOpening, + markProjectOpening, + markTerminalOpening, + } = options.openState + + function addProject() { + initializeNewProjectState() + options.navigateFromHome(ViewEnum.ProjectEditor) + } + + function editProject(project: LocalProject) { + initializeUpdateProjectState(project) + options.navigateFromHome(ViewEnum.ProjectEditor) + } + + async function openProject(project: LocalProject) { + if (isProjectOpening(project)) + return + + await openProjectWithEditor(project, project.defaultOpen) + } + + async function openProjectWithEditor(project: LocalProject, editor: CodeEditorEnum) { + if (!project.isExists || isProjectOpening(project)) + return + + if (project.isRemote) { + await openRemoteProjectWithEditor(project, editor) + return + } + + const launchConfig = settingsStore.getEditorLaunchConfig(editor) + if (!launchConfig?.command) { + showMissingEditorCommandToast() + return + } + + markProjectOpening(project.appendTime) + try { + await window.api.openProject(launchConfig.command, project.path, launchConfig.openInTerminal) + void projectsStore.markProjectOpened(project.appendTime) + finishProjectOpening(project.appendTime) + } + catch (error: unknown) { + clearProjectOpening(project.appendTime) + console.error('Failed to open project:', error) + showToast({ + tone: 'error', + title: t('app.home.feedback.open_failed'), + description: formatActionError(error), + }) + } + } + + async function openRemoteProjectWithEditor(project: LocalProject, editor: CodeEditorEnum) { + if (!project.remoteHost || !project.remotePath) + return + + if (!isVscodeHistoryScannerEditor(editor)) { + showToast({ + tone: 'error', + title: t('app.home.feedback.open_failed'), + description: t('app.home.feedback.remote_editor_unsupported'), + }) + return + } + + const launchConfig = settingsStore.getEditorLaunchConfig(editor) + if (!launchConfig?.command) { + showMissingEditorCommandToast() + return + } + + markProjectOpening(project.appendTime) + try { + await window.api.openRemoteProject(project.remoteHost, project.remotePath, launchConfig.command, 'vscode') + void projectsStore.markProjectOpened(project.appendTime) + finishProjectOpening(project.appendTime) + } + catch (error: unknown) { + clearProjectOpening(project.appendTime) + console.error('Failed to open remote project:', error) + showToast({ + tone: 'error', + title: t('app.home.feedback.open_failed'), + description: formatActionError(error), + }) + } + } + + function clearProjectFromMaps(projectId: number) { + options.deleteProjectRefs(projectId) + clearCopyFeedback(projectId) + clearOpenStateFromMaps(projectId) + } + + async function copyProjectPath(project: LocalProject) { + try { + if (!navigator.clipboard?.writeText) + throw new Error('Clipboard API is not available') + + await navigator.clipboard.writeText(project.path) + setCopyFeedback(project.appendTime, 'success') + } + catch (error) { + console.error('Failed to copy project path:', error) + setCopyFeedback(project.appendTime, 'error') + } + } + + function setCopyFeedback(projectId: number, status: 'success' | 'error') { + const activeTimer = copyFeedbackTimers.get(projectId) + if (activeTimer) + window.clearTimeout(activeTimer) + + copyFeedback.value = { + ...copyFeedback.value, + [projectId]: status, + } + + const timer = window.setTimeout(() => { + const { [projectId]: _removed, ...rest } = copyFeedback.value + copyFeedback.value = rest + copyFeedbackTimers.delete(projectId) + }, 1400) + + copyFeedbackTimers.set(projectId, timer) + } + + function clearCopyFeedback(projectId: number) { + const copyTimer = copyFeedbackTimers.get(projectId) + if (copyTimer) { + window.clearTimeout(copyTimer) + copyFeedbackTimers.delete(projectId) + } + } + + function clearCopyFeedbackTimers() { + copyFeedbackTimers.forEach(timer => window.clearTimeout(timer)) + copyFeedbackTimers.clear() + } + + function scanResultDescription(result: ProjectScannerImportResult, changedStatusCount: number) { + const parts: string[] = [] + + if (result.added > 0) + parts.push(t('app.home.feedback.scan_added', { count: result.added })) + if (changedStatusCount > 0) + parts.push(t('app.home.feedback.scan_updated', { count: changedStatusCount })) + if (result.failed > 0) + parts.push(t('app.home.feedback.scan_failed_items', { count: result.failed })) + + return parts.length > 0 + ? parts.join(t('app.common.list_separator')) + : t('app.home.feedback.scan_no_changes') + } + + function showScanResultToast(result: ProjectScannerImportResult, changedStatusCount: number, elapsed: number) { + const hasVisibleResult = result.added > 0 || result.failed > 0 || changedStatusCount > 0 + if (!hasVisibleResult && elapsed < SCAN_RESULT_TOAST_THRESHOLD_MS) + return + + showToast({ + tone: result.failed > 0 ? 'warning' : hasVisibleResult ? 'success' : 'info', + title: t('app.home.feedback.scan_completed'), + description: scanResultDescription(result, changedStatusCount), + }) + } + + function openProjectSource(project: LocalProject) { + const sourceUrl = projectSourceExternalUrl(project) + if (sourceUrl) + window.api.openExternal(sourceUrl) + } + + async function toggleProjectPinned(project: LocalProject) { + await projectsStore.toggleProjectPinned(project.appendTime) + } + + async function toggleProjectArchived(project: LocalProject) { + await projectsStore.toggleProjectArchived(project.appendTime) + } + + async function setProjectDefaultEditor(project: LocalProject, editor: CodeEditorEnum) { + await projectsStore.setProjectDefaultOpen(project.appendTime, editor) + } + + async function setProjectDefaultEditorAutomatically(project: LocalProject) { + const editor = editorLangGroupsStore.getEditorByLanguage(project.mainLang, project.defaultOpen) ?? project.defaultOpen + await setProjectDefaultEditor(project, editor) + } + + function handleProjectMenuSelect(project: LocalProject, action: string) { + if (action.startsWith(OPEN_WITH_ACTION_PREFIX)) { + const editor = action.slice(OPEN_WITH_ACTION_PREFIX.length) + if (isCodeEditor(editor)) + void openProjectWithEditor(project, editor) + return + } + + if (action === DEFAULT_EDITOR_AUTO_ACTION) { + void setProjectDefaultEditorAutomatically(project) + return + } + + if (action.startsWith(DEFAULT_EDITOR_ACTION_PREFIX)) { + const editor = action.slice(DEFAULT_EDITOR_ACTION_PREFIX.length) + if (isCodeEditor(editor)) + void setProjectDefaultEditor(project, editor) + return + } + + switch (action) { + case 'pin': + void toggleProjectPinned(project) + break + case 'archive': + void toggleProjectArchived(project) + break + case 'source': + case 'open-source': + openProjectSource(project) + break + case 'open': + void openProject(project) + break + case 'explorer': + openInExplorer(project) + break + case 'terminal': + void openInTerminal(project) + break + case 'copy': + case 'copy-path': + void copyProjectPath(project) + break + case 'edit': + editProject(project) + break + case 'remove': + showRemoveDialog(project) + break + } + } + + function openInExplorer(project: LocalProject) { + window.api.openInExplorer(project.path) + } + + async function openInTerminal(project: LocalProject) { + if (!project.isExists || isTerminalOpening(project)) + return + + markTerminalOpening(project.appendTime) + try { + if (project.isRemote && project.remoteHost && project.remotePath) { + await window.api.openRemoteProject(project.remoteHost, project.remotePath, '', 'terminal') + } + else { + await window.api.openInTerminal(project.path, settingsStore.terminalCommand) + } + finishTerminalOpening(project.appendTime) + } + catch (error) { + clearTerminalOpening(project.appendTime) + console.error('Failed to open project in terminal:', error) + showToast({ + tone: 'error', + title: t('app.home.feedback.terminal_failed'), + description: formatActionError(error), + }) + } + } + + async function syncProjects() { + if (!settingsStore.scannerEnabled || syncing.value) + return + + const startedAt = performance.now() + let workElapsed = 0 + let changedStatusCount = 0 + let scanResult: ProjectScannerImportResult | null = null + let scanError: unknown = null + manualSyncing.value = true + try { + changedStatusCount = await projectsStore.refreshProjectExistence() + scanResult = await addNewProjectsFromScanner() + } + catch (error) { + console.error('Project scanner failed:', error) + scanError = error + } + finally { + workElapsed = performance.now() - startedAt + if (workElapsed < MIN_SYNC_BUSY_MS) + await new Promise(resolve => setTimeout(resolve, MIN_SYNC_BUSY_MS - workElapsed)) + manualSyncing.value = false + } + + if (scanError) { + showToast({ + tone: 'error', + title: t('app.home.feedback.scan_failed'), + description: formatActionError(scanError), + }) + return + } + + if (scanResult) + showScanResultToast(scanResult, changedStatusCount, workElapsed) + } + + function showLanguage(project: LocalProject, event: MouseEvent) { + showLanguagePop(project, event.currentTarget as HTMLElement) + } + + function openSettings() { + options.navigateFromHome(ViewEnum.Settings) + } + + function openEditorSettings() { + activatedPage.value = SettingPageEnum.Ides + options.navigateFromHome(ViewEnum.Settings) + } + + function showMissingEditorCommandToast() { + showToast({ + tone: 'warning', + title: t('no_ide_path_dialog.dialog_title'), + description: t('no_ide_path_dialog.dialog_desc'), + action: { + label: t('no_ide_path_dialog.set'), + onSelect: openEditorSettings, + }, + }) + } + + return { + addProject, + clearCopyFeedbackTimers, + clearProjectFromMaps, + copyFeedback, + copyProjectPath, + editProject, + handleProjectMenuSelect, + openInExplorer, + openInTerminal, + openProject, + openProjectSource, + openSettings, + showLanguage, + syncing, + syncProjects, + } +} diff --git a/src/views/HomeView/composables/useProjectFilters.ts b/src/views/HomeView/composables/useProjectFilters.ts index 35e6f3a..e089752 100644 --- a/src/views/HomeView/composables/useProjectFilters.ts +++ b/src/views/HomeView/composables/useProjectFilters.ts @@ -4,6 +4,7 @@ import { computed, ref } from 'vue' import type { LocalProject } from '~/constants/localProject' import { ProjectKind } from '~/constants/localProject' import { useProjectsStore } from '~/stores/projectsStore' +import { formatProjectLanguage } from '~/utils/projectFormatters' type KindFilter = ProjectKind | 'all' type StatusFilter = 'all' | 'available' | 'missing' | 'temporary' | 'archived' @@ -111,7 +112,7 @@ export function useProjectFilters( { value: 'all', label: t('app.home.filters.all_languages'), count: totalProjects.value }, ...languages.value.map(language => ({ value: language.text, - label: language.text === 'unknown' ? t('app.common.unknown') : language.text, + label: formatProjectLanguage(language.text, t), color: language.color, count: language.count, })), diff --git a/src/views/HomeView/composables/useProjectKeyboardNavigation.ts b/src/views/HomeView/composables/useProjectKeyboardNavigation.ts new file mode 100644 index 0000000..0313eb0 --- /dev/null +++ b/src/views/HomeView/composables/useProjectKeyboardNavigation.ts @@ -0,0 +1,390 @@ +import type { Ref } from 'vue' + +import { showRemoveDialog } from '~/components/RemoveProjectDialog' +import type { LocalProject } from '~/constants/localProject' +import { PROJECT_ACTION_KEYS } from '~/constants/projectActions' + +import type { LayoutMode, ProjectActionKey } from '../types' + +interface ProjectRefs { + projectItemRefs: Map + projectActionRefs: Map +} + +interface ProjectKeyboardNavigationOptions { + filteredProjects: Ref + layoutMode: Ref + refs: ProjectRefs + ensureProjectRendered: (project: LocalProject) => Promise + openProject: (project: LocalProject) => Promise + editProject: (project: LocalProject) => void + copyProjectPath: (project: LocalProject) => Promise +} + +export function useProjectKeyboardNavigation(options: ProjectKeyboardNavigationOptions) { + const { + filteredProjects, + layoutMode, + refs, + ensureProjectRendered, + openProject, + editProject, + copyProjectPath, + } = options + + async function focusProject(project: LocalProject | undefined) { + if (!project?.isExists) + return + + await ensureProjectRendered(project) + + const element = refs.projectItemRefs.get(project.appendTime) + if (!element) + return + + element.focus({ preventScroll: true }) + element.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + + function findProjectByLinearOffset(project: LocalProject, offset: number, includeMissing = false) { + const projects = filteredProjects.value.filter(item => includeMissing || item.isExists) + const currentIndex = projects.findIndex(item => item.appendTime === project.appendTime) + if (currentIndex < 0) + return undefined + + const targetIndex = Math.max(0, Math.min(projects.length - 1, currentIndex + offset)) + return projects[targetIndex] + } + + function focusProjectByLinearOffset(project: LocalProject, offset: number) { + focusProject(findProjectByLinearOffset(project, offset)) + } + + function focusProjectAtEdge(edge: 'start' | 'end') { + const focusableProjects = filteredProjects.value.filter(item => item.isExists) + focusProject(edge === 'start' ? focusableProjects[0] : focusableProjects.at(-1)) + } + + function openProjectContextMenuFromKeyboard(event: KeyboardEvent) { + const target = event.currentTarget + if (!(target instanceof HTMLElement)) + return + + const rect = target.getBoundingClientRect() + target.dispatchEvent(new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: rect.left + Math.min(36, rect.width / 2), + clientY: rect.top + Math.min(24, rect.height / 2), + })) + } + + function findProjectByGridDirection(project: LocalProject, direction: 'left' | 'right' | 'up' | 'down', includeMissing = false) { + const currentElement = refs.projectItemRefs.get(project.appendTime) + if (!currentElement) + return findProjectByGridLinearDirection(project, direction, includeMissing) + + const currentRect = currentElement.getBoundingClientRect() + const currentCenterX = currentRect.left + currentRect.width / 2 + const sameRowTolerance = Math.max(8, currentRect.height / 2) + + const candidates = filteredProjects.value + .filter(item => (includeMissing || item.isExists) && item.appendTime !== project.appendTime) + .map((item) => { + const element = refs.projectItemRefs.get(item.appendTime) + return element + ? { project: item, rect: element.getBoundingClientRect() } + : null + }) + .filter(item => item !== null) + + if (direction === 'left' || direction === 'right') { + const sameRow = candidates + .filter(item => Math.abs(item.rect.top - currentRect.top) <= sameRowTolerance) + .filter(item => direction === 'left' ? item.rect.left < currentRect.left : item.rect.left > currentRect.left) + .sort((a, b) => direction === 'left' ? b.rect.left - a.rect.left : a.rect.left - b.rect.left) + + if (sameRow[0]) + return sameRow[0].project + + return findProjectByLinearOffset(project, direction === 'left' ? -1 : 1, includeMissing) + } + + const verticalCandidates = candidates + .filter(item => direction === 'up' ? item.rect.top < currentRect.top - 4 : item.rect.top > currentRect.top + 4) + .sort((a, b) => { + const rowDistanceA = Math.abs(a.rect.top - currentRect.top) + const rowDistanceB = Math.abs(b.rect.top - currentRect.top) + if (rowDistanceA !== rowDistanceB) + return rowDistanceA - rowDistanceB + + const centerA = a.rect.left + a.rect.width / 2 + const centerB = b.rect.left + b.rect.width / 2 + return Math.abs(centerA - currentCenterX) - Math.abs(centerB - currentCenterX) + }) + + return verticalCandidates[0]?.project || findProjectByGridLinearDirection(project, direction, includeMissing) + } + + function focusCardByDirection(project: LocalProject, direction: 'left' | 'right' | 'up' | 'down') { + focusProject(findProjectByGridDirection(project, direction)) + } + + function findProjectByGridLinearDirection(project: LocalProject, direction: 'left' | 'right' | 'up' | 'down', includeMissing = false) { + const projects = filteredProjects.value.filter(item => includeMissing || item.isExists) + const currentIndex = projects.findIndex(item => item.appendTime === project.appendTime) + if (currentIndex < 0) + return undefined + + const columnCount = getRenderedGridColumnCount() + const offset = { + left: -1, + right: 1, + up: -columnCount, + down: columnCount, + }[direction] + + const targetIndex = Math.max(0, Math.min(projects.length - 1, currentIndex + offset)) + return projects[targetIndex] + } + + function getRenderedGridColumnCount() { + const firstElement = refs.projectItemRefs.values().next().value + if (!(firstElement instanceof HTMLElement)) + return 1 + + const firstTop = firstElement.getBoundingClientRect().top + let columnCount = 0 + + for (const element of refs.projectItemRefs.values()) { + if (Math.abs(element.getBoundingClientRect().top - firstTop) <= 4) + columnCount += 1 + } + + return Math.max(1, columnCount) + } + + function isProjectActionDisabled(project: LocalProject, action: ProjectActionKey) { + if (project.isRemote && action === 'explorer') + return true + return !project.isExists && (action === 'open' || action === 'explorer' || action === 'terminal') + } + + function firstEnabledProjectAction(project: LocalProject) { + return PROJECT_ACTION_KEYS.find(action => !isProjectActionDisabled(project, action)) || 'more' + } + + function actionButtonTabIndex(project: LocalProject, action: ProjectActionKey) { + if (isProjectActionDisabled(project, action)) + return -1 + + return firstEnabledProjectAction(project) === action ? 0 : -1 + } + + function projectActionKey(button: HTMLButtonElement) { + const action = button.dataset.projectAction + return PROJECT_ACTION_KEYS.includes(action as ProjectActionKey) + ? action as ProjectActionKey + : null + } + + function toolbarActionButtons(toolbar: HTMLElement) { + return Array.from(toolbar.querySelectorAll('[data-project-action]')) + .filter(button => !button.disabled && button.getClientRects().length > 0) + } + + function focusToolbarButtonAtIndex(toolbar: HTMLElement, index: number) { + const buttons = toolbarActionButtons(toolbar) + if (!buttons.length) + return + + const targetIndex = Math.max(0, Math.min(buttons.length - 1, index)) + buttons[targetIndex]?.focus({ preventScroll: true }) + } + + async function focusProjectAction(project: LocalProject | undefined, preferredAction: ProjectActionKey | null, preferredIndex: number) { + if (!project) + return + + await ensureProjectRendered(project) + + const toolbar = refs.projectActionRefs.get(project.appendTime) + if (!toolbar) + return + + const buttons = toolbarActionButtons(toolbar) + if (!buttons.length) + return + + const sameActionButton = preferredAction + ? buttons.find(button => projectActionKey(button) === preferredAction) + : undefined + const fallbackIndex = Math.max(0, Math.min(buttons.length - 1, preferredIndex)) + const target = sameActionButton || buttons[fallbackIndex] || buttons[0] + + target.focus({ preventScroll: true }) + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + + function handleProjectActionsKeydown(project: LocalProject, event: KeyboardEvent) { + if (event.defaultPrevented) + return + + if (handleProjectShortcut(project, event)) + return + + const toolbar = event.currentTarget + if (!(toolbar instanceof HTMLElement)) + return + + const target = event.target instanceof HTMLElement + ? event.target.closest('[data-project-action]') + : null + const buttons = toolbarActionButtons(toolbar) + const currentIndex = target ? buttons.indexOf(target) : -1 + const action = target ? projectActionKey(target) : null + + if (!buttons.length) + return + + const focusHorizontalAction = (offset: number) => { + event.preventDefault() + event.stopPropagation() + focusToolbarButtonAtIndex(toolbar, currentIndex < 0 ? 0 : currentIndex + offset) + } + + const focusVerticalAction = (offset: number, direction: 'up' | 'down') => { + event.preventDefault() + event.stopPropagation() + const targetProject = layoutMode.value === 'grid' + ? findProjectByGridDirection(project, direction, true) + : findProjectByLinearOffset(project, offset, true) + focusProjectAction(targetProject, action, currentIndex) + } + + switch (event.key) { + case 'ArrowLeft': + focusHorizontalAction(-1) + break + case 'ArrowRight': + focusHorizontalAction(1) + break + case 'ArrowUp': + focusVerticalAction(-1, 'up') + break + case 'ArrowDown': + focusVerticalAction(1, 'down') + break + case 'Home': + event.preventDefault() + event.stopPropagation() + focusToolbarButtonAtIndex(toolbar, 0) + break + case 'End': + event.preventDefault() + event.stopPropagation() + focusToolbarButtonAtIndex(toolbar, buttons.length - 1) + break + } + } + + function handleProjectShortcut(project: LocalProject, event: KeyboardEvent) { + const usesCommandModifier = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey + const key = event.key.toLowerCase() + + if (usesCommandModifier && key === 'i') { + event.preventDefault() + event.stopPropagation() + editProject(project) + return true + } + + if (usesCommandModifier && key === 'c') { + event.preventDefault() + event.stopPropagation() + void copyProjectPath(project) + return true + } + + const isRemoveKey = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey + && (event.key === 'Delete' || event.key === 'Backspace') + if (isRemoveKey) { + event.preventDefault() + event.stopPropagation() + showRemoveDialog(project) + return true + } + + return false + } + + function handleProjectContainerKeydown(project: LocalProject, event: KeyboardEvent) { + if (event.target !== event.currentTarget) + return + + if (handleProjectShortcut(project, event)) + return + + switch (event.key) { + case 'Enter': + event.preventDefault() + void openProject(project) + break + case ' ': + event.preventDefault() + void openProject(project) + break + case 'ContextMenu': + event.preventDefault() + openProjectContextMenuFromKeyboard(event) + break + case 'F10': + if (!event.shiftKey) + return + event.preventDefault() + openProjectContextMenuFromKeyboard(event) + break + case 'ArrowUp': + event.preventDefault() + if (layoutMode.value === 'grid') + focusCardByDirection(project, 'up') + else + focusProjectByLinearOffset(project, -1) + break + case 'ArrowDown': + event.preventDefault() + if (layoutMode.value === 'grid') + focusCardByDirection(project, 'down') + else + focusProjectByLinearOffset(project, 1) + break + case 'ArrowLeft': + if (layoutMode.value !== 'grid') + return + event.preventDefault() + focusCardByDirection(project, 'left') + break + case 'ArrowRight': + if (layoutMode.value !== 'grid') + return + event.preventDefault() + focusCardByDirection(project, 'right') + break + case 'Home': + event.preventDefault() + focusProjectAtEdge('start') + break + case 'End': + event.preventDefault() + focusProjectAtEdge('end') + break + } + } + + return { + actionButtonTabIndex, + handleProjectActionsKeydown, + handleProjectContainerKeydown, + isProjectActionDisabled, + } +} diff --git a/src/views/HomeView/composables/useProjectMenus.ts b/src/views/HomeView/composables/useProjectMenus.ts index 3e6198c..c4fd7fe 100644 --- a/src/views/HomeView/composables/useProjectMenus.ts +++ b/src/views/HomeView/composables/useProjectMenus.ts @@ -2,13 +2,15 @@ import type { UiActionMenuItem } from '~/components/ui/actionMenu' import type { CodeEditorEnum } from '~/constants/codeEditor' import { editorCommandOptions, isVscodeHistoryScannerEditor } from '~/constants/codeEditor' import type { LocalProject } from '~/constants/localProject' -import { ProjectKind } from '~/constants/localProject' - import { DEFAULT_EDITOR_ACTION_PREFIX, DEFAULT_EDITOR_AUTO_ACTION, OPEN_WITH_ACTION_PREFIX, -} from '../constants' +} from '~/constants/projectActions' +import { + isSourceProject, + projectSourceUrl, +} from '~/utils/projectSource' /** * 项目菜单构建 @@ -68,14 +70,6 @@ export function useProjectMenus(deps: MenuDependencies) { return project.isTemporary && project.isExists !== false && !project.isRemote } - function isSourceProject(project: LocalProject) { - return project.kind === ProjectKind.FORK || project.kind === ProjectKind.CLONE - } - - function projectSourceUrl(project: LocalProject) { - return isSourceProject(project) ? project.fromUrl?.trim() || '' : '' - } - function projectMenuItems(project: LocalProject): UiActionMenuItem[] { const canDeleteFiles = canDeleteProjectFiles(project) const sourceUrl = projectSourceUrl(project) diff --git a/src/views/HomeView/composables/useProjectRefs.ts b/src/views/HomeView/composables/useProjectRefs.ts new file mode 100644 index 0000000..0ebf3df --- /dev/null +++ b/src/views/HomeView/composables/useProjectRefs.ts @@ -0,0 +1,58 @@ +import type { LocalProject } from '~/constants/localProject' + +export function useProjectRefs() { + const projectItemRefs = new Map() + const projectActionRefs = new Map() + + function setProjectItemRef(projectId: number, element: unknown) { + if (element instanceof HTMLElement) { + projectItemRefs.set(projectId, element) + return + } + + projectItemRefs.delete(projectId) + } + + function setProjectActionsRef(projectId: number, element: unknown) { + if (element instanceof HTMLElement) { + projectActionRefs.set(projectId, element) + return + } + + projectActionRefs.delete(projectId) + } + + function deleteProjectRefs(projectId: LocalProject['appendTime']) { + projectItemRefs.delete(projectId) + projectActionRefs.delete(projectId) + } + + function clearProjectRefs() { + projectItemRefs.clear() + projectActionRefs.clear() + } + + async function playProjectRemoveAnimation(projectId: number): Promise { + const element = projectItemRefs.get(projectId) + if (!element) + return + + element.style.pointerEvents = 'none' + element.style.transformOrigin = 'center' + element.style.transition = 'opacity 180ms ease-out, transform 180ms ease-out' + element.style.opacity = '0' + element.style.transform = 'scale(0.985)' + + await new Promise(resolve => setTimeout(resolve, 180)) + } + + return { + projectItemRefs, + projectActionRefs, + setProjectItemRef, + setProjectActionsRef, + deleteProjectRefs, + clearProjectRefs, + playProjectRemoveAnimation, + } +} diff --git a/src/views/HomeView/constants.ts b/src/views/HomeView/constants.ts index cc2d5ca..4c005aa 100644 --- a/src/views/HomeView/constants.ts +++ b/src/views/HomeView/constants.ts @@ -4,12 +4,3 @@ export const MIN_SYNC_BUSY_MS = 280 export const SCAN_RESULT_TOAST_THRESHOLD_MS = 700 -export const HOME_LAYOUT_STORAGE_KEY = 'codenest:home-layout' -export const GITHUB_SOURCE_RE = /github\.com[/:]([^/:\s]+\/[^/\s#.]+)/ - -export const PROJECT_ACTION_KEYS = ['open', 'explorer', 'terminal', 'copy', 'more'] as const - -// Menu action prefixes -export const OPEN_WITH_ACTION_PREFIX = 'open-with:' -export const DEFAULT_EDITOR_ACTION_PREFIX = 'default-editor:' -export const DEFAULT_EDITOR_AUTO_ACTION = `${DEFAULT_EDITOR_ACTION_PREFIX}auto` diff --git a/src/views/HomeView/types.ts b/src/views/HomeView/types.ts index 9fccdb7..7854b29 100644 --- a/src/views/HomeView/types.ts +++ b/src/views/HomeView/types.ts @@ -8,4 +8,4 @@ export interface ScrollAreaRef { export type LayoutMode = 'list' | 'grid' -export type ProjectActionKey = 'open' | 'explorer' | 'terminal' | 'copy' | 'more' +export type { ProjectActionKey } from '~/constants/projectActions' diff --git a/src/views/HomeView/utils/homeLayoutStorage.ts b/src/views/HomeView/utils/homeLayoutStorage.ts new file mode 100644 index 0000000..31df86d --- /dev/null +++ b/src/views/HomeView/utils/homeLayoutStorage.ts @@ -0,0 +1,22 @@ +import { HOME_LAYOUT_STORAGE_KEY } from '~/constants/storageKeys' + +import type { LayoutMode } from '../types' + +export function readStoredLayoutMode(): LayoutMode { + try { + const value = window.localStorage.getItem(HOME_LAYOUT_STORAGE_KEY) + return value === 'grid' || value === 'list' ? value : 'list' + } + catch { + return 'list' + } +} + +export function persistLayoutMode(value: LayoutMode) { + try { + window.localStorage.setItem(HOME_LAYOUT_STORAGE_KEY, value) + } + catch { + // The layout toggle still works for the current session. + } +} diff --git a/src/views/HomeView/utils/projectFormatters.ts b/src/views/HomeView/utils/projectFormatters.ts new file mode 100644 index 0000000..3a3c7b7 --- /dev/null +++ b/src/views/HomeView/utils/projectFormatters.ts @@ -0,0 +1,46 @@ +import type { CodeEditorEnum } from '~/constants/codeEditor' +import { codeEditors } from '~/constants/codeEditor' +import type { LocalProject } from '~/constants/localProject' +import { ProjectKind } from '~/constants/localProject' + +export function kindClass(kind: ProjectKind) { + switch (kind) { + case ProjectKind.FORK: + return 'kind-fork' + case ProjectKind.CLONE: + return 'kind-clone' + case ProjectKind.MINE: + default: + return '' + } +} + +export function editorIconClasses(editor?: CodeEditorEnum | null) { + if (!editor) + return [] + const option = codeEditors[editor] + return ['ide-icon', option?.icon, { 'monochrome-editor-icon': option?.monochromeIcon }] +} + +export function editorLabel(editor: CodeEditorEnum, t: (key: string) => string) { + return codeEditors[editor]?.label || t('app.common.no_editor') +} + +export function formatTime(timestamp: number, locale: string) { + return new Intl.DateTimeFormat(locale, { + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestamp)) +} + +export function formatLastOpened( + project: LocalProject, + locale: string, + t: (key: string) => string, +) { + return project.lastOpenedAt + ? formatTime(project.lastOpenedAt, locale) + : t('app.home.table.never_opened') +} diff --git a/src/views/HomeView/utils/projectSource.ts b/src/views/HomeView/utils/projectSource.ts new file mode 100644 index 0000000..359dbb2 --- /dev/null +++ b/src/views/HomeView/utils/projectSource.ts @@ -0,0 +1,21 @@ +import type { LocalProject } from '~/constants/localProject' +import { ProjectKind } from '~/constants/localProject' +import { + projectSourceName, + projectSourceUrl, +} from '~/utils/projectSource' + +export function projectSourcePrefix(project: LocalProject, t: (key: string) => string) { + return project.kind === ProjectKind.FORK + ? t('app.home.source.forked_from') + : t('app.home.source.cloned_from') +} + +export function projectSourceTitle(project: LocalProject, t: (key: string) => string) { + const sourceName = projectSourceName(project) + const sourceUrl = projectSourceUrl(project) + const prefix = projectSourcePrefix(project, t) + if (sourceName && sourceUrl && sourceName !== sourceUrl) + return `${prefix} ${sourceName}\n${sourceUrl}` + return sourceName ? `${prefix} ${sourceName}` : sourceUrl +} diff --git a/src/views/ProjectEditorView/ProjectEditorView.vue b/src/views/ProjectEditorView/ProjectEditorView.vue index 3e828aa..1c602d7 100644 --- a/src/views/ProjectEditorView/ProjectEditorView.vue +++ b/src/views/ProjectEditorView/ProjectEditorView.vue @@ -1,1250 +1,121 @@ - - - - - - - - {{ isUpdateProject ? t('app.project_editor.title.edit') : t('app.project_editor.title.add') }} - - - - - {{ t('app.common.cancel') }} - - - {{ isUpdateProject ? t('app.project_editor.save_changes') : t('app.project_editor.add_project') }} - - - - - - - - - - {{ t('app.project_editor.sections.project') }} - - - - - {{ t('app.project_editor.fields.location') }} - - - - - - - {{ t('app.project_editor.fields.folder') }} - - - - - - - - - - {{ folderStatus }} - - - - - - - - {{ t('app.project_editor.fields.remote_host') }} - {{ t('app.project_editor.fields.remote_host_desc') }} - - - - - - - {{ folderStatus }} - - - - - - - {{ t('app.project_editor.fields.remote_path') }} - - - - - - - - - - {{ t('app.project_editor.fields.name') }} - - - - - {{ t('app.project_editor.use_folder') }} - - - - - - - {{ t('app.project_editor.fields.type') }} - - - - - - - - - {{ t('app.project_editor.fields.source_url') }} - - - - {{ t('app.project_editor.fields.source_name') }} - - - - {{ t('app.project_editor.use_repo') }} - - - - - - - - - - {{ t('app.project_editor.fields.group') }} - {{ t('app.project_editor.fields.group_desc') }} - - - - - - - - - - - - {{ t('app.project_editor.fields.temporary') }} - {{ t('app.project_editor.fields.temporary_desc') }} - - - - - - - - {{ t('app.project_editor.sections.metadata') }} - - - - - {{ t('app.project_editor.fields.main_language') }} - {{ t('app.project_editor.fields.main_language_desc') }} - - - - - - {{ visibleAnalyzing ? t('app.project_editor.analyzing') : t('app.project_editor.analyze') }} - - - - {{ mainLanguageStatus }} - - - - - - - {{ t('app.project_editor.fields.open_with') }} - - - - - - - {{ t('app.project_editor.fields.license') }} - - - - - - {{ t('app.common.detect') }} - - - - - - - + + + + + + + + diff --git a/src/views/ProjectEditorView/components/ProjectEditorFormPanel.vue b/src/views/ProjectEditorView/components/ProjectEditorFormPanel.vue new file mode 100644 index 0000000..e4c7f7b --- /dev/null +++ b/src/views/ProjectEditorView/components/ProjectEditorFormPanel.vue @@ -0,0 +1,445 @@ + + + + + + + {{ t('app.project_editor.sections.project') }} + + + + + {{ t('app.project_editor.fields.location') }} + + + + + + + {{ t('app.project_editor.fields.folder') }} + + + + + + + + + + {{ folderStatus }} + + + + + + + + {{ t('app.project_editor.fields.remote_host') }} + {{ t('app.project_editor.fields.remote_host_desc') }} + + + + + + + + + + + {{ t('app.project_editor.fields.remote_path') }} + + + + + + + {{ folderStatus }} + + + + + + + + {{ t('app.project_editor.fields.name') }} + + + + + {{ t('app.project_editor.use_folder') }} + + + + + + + {{ t('app.project_editor.fields.type') }} + + + + + + + + + {{ t('app.project_editor.fields.source_url') }} + + + + {{ t('app.project_editor.fields.source_name') }} + + + + {{ t('app.project_editor.use_repo') }} + + + + + + + + + + {{ t('app.project_editor.fields.group') }} + {{ t('app.project_editor.fields.group_desc') }} + + + + + + + + + + + + {{ t('app.project_editor.fields.temporary') }} + {{ t('app.project_editor.fields.temporary_desc') }} + + + + + + + + {{ t('app.project_editor.sections.metadata') }} + + + + + {{ t('app.project_editor.fields.main_language') }} + {{ t('app.project_editor.fields.main_language_desc') }} + + + + + + {{ visibleAnalyzing ? t('app.project_editor.analyzing') : t('app.project_editor.analyze') }} + + + + {{ mainLanguageStatus }} + + + + + + + {{ t('app.project_editor.fields.open_with') }} + + + + + + + {{ t('app.project_editor.fields.license') }} + + + + + + {{ t('app.common.detect') }} + + + + + + + + + diff --git a/src/views/ProjectEditorView/components/ProjectEditorHeader.vue b/src/views/ProjectEditorView/components/ProjectEditorHeader.vue new file mode 100644 index 0000000..471e083 --- /dev/null +++ b/src/views/ProjectEditorView/components/ProjectEditorHeader.vue @@ -0,0 +1,63 @@ + + + + + + + + + + + {{ isUpdate ? t('app.project_editor.title.edit') : t('app.project_editor.title.add') }} + + + + + + {{ t('app.common.cancel') }} + + + {{ isUpdate ? t('app.project_editor.save_changes') : t('app.project_editor.add_project') }} + + + + + + diff --git a/src/views/ProjectEditorView/components/ProjectEditorSummaryPanel.vue b/src/views/ProjectEditorView/components/ProjectEditorSummaryPanel.vue new file mode 100644 index 0000000..1848c27 --- /dev/null +++ b/src/views/ProjectEditorView/components/ProjectEditorSummaryPanel.vue @@ -0,0 +1,214 @@ + + + + + + + diff --git a/src/views/ProjectEditorView/composables/useProjectEditor.ts b/src/views/ProjectEditorView/composables/useProjectEditor.ts new file mode 100644 index 0000000..2296ce1 --- /dev/null +++ b/src/views/ProjectEditorView/composables/useProjectEditor.ts @@ -0,0 +1,441 @@ +import { useI18n } from 'vue-i18n' + +import { ViewEnum } from '~/constants/appEnums' +import type { CodeEditorEnum, CodeEditorOption } from '~/constants/codeEditor' +import { codeEditorOrder, codeEditors, isVscodeHistoryScannerEditor } from '~/constants/codeEditor' +import { LicenseEnum } from '~/constants/license' +import type { LocalProject } from '~/constants/localProject' +import { ProjectKind } from '~/constants/localProject' +import { LanguageAnalyzer } from '~/services/languageAnalyzer' +import { detectLicenseBySnippet } from '~/services/licenseDetector' +import { useEditorLangGroupsStore } from '~/stores/editorLangGroupsStore' +import { useProjectScannerStore } from '~/stores/projectScannerStore' +import { useProjectsStore } from '~/stores/projectsStore' +import { kindLabel, shortLicense } from '~/utils/projectFormatters' +import { extractGithubRepositoryName } from '~/utils/projectSource' +import { useDelayedBusy } from '~/utils/useDelayedBusy' + +import { + initializeNewProjectState, + isUpdateProject, + localProjectItem, +} from '../ProjectEditorViewProvider' + +export type ProjectEditorFieldKey = 'path' | 'name' | 'mainLang' | 'defaultOpen' | 'remoteHost' | 'remotePath' + +const PATH_SPLIT_RE = /[\\/]+/ + +export function useProjectEditor() { + const activatedView = inject('activatedView') as Ref + const projectsStore = useProjectsStore() + const projectScannerStore = useProjectScannerStore() + const editorLangGroupsStore = useEditorLangGroupsStore() + const initialProjectPath = localProjectItem.value.path || '' + const { t } = useI18n() + + const isRemote = computed(() => !!localProjectItem.value.isRemote) + const analyzing = ref(false) + const detectingLicense = ref(false) + const visibleAnalyzing = useDelayedBusy(analyzing, { delay: 220, minDuration: 320 }) + const lastAnalyzedPath = ref(initialProjectPath) + const touchedFields = reactive>({ + path: false, + name: false, + mainLang: false, + defaultOpen: false, + remoteHost: false, + remotePath: false, + }) + + const editorRows = computed>(() => + codeEditorOrder.map(editor => [editor, codeEditors[editor]]), + ) + + function editorGroupLabel(option: CodeEditorOption) { + return t(option.groupKey) + } + + const editorOptions = computed(() => + editorRows.value + .filter(([editor]) => !isRemote.value || isVscodeHistoryScannerEditor(editor)) + .map(([editor, option]) => ({ + value: editor, + label: `${editorGroupLabel(option)} / ${option.label}`, + })), + ) + + const modeOptions = computed(() => [ + { value: 'local', label: t('app.project_editor.mode.local') }, + { value: 'remote', label: t('app.project_editor.mode.remote') }, + ]) + + const projectMode = computed(() => (isRemote.value ? 'remote' : 'local')) + + const licenseOptions = computed(() => + [ + LicenseEnum.NONE, + ...Object.values(LicenseEnum).filter(value => value !== LicenseEnum.NONE), + ].map(value => ({ + value, + label: shortLicense(value, t), + })), + ) + + const projectKindOptions = computed(() => + [ProjectKind.MINE, ProjectKind.FORK, ProjectKind.CLONE].map(kind => ({ + value: kind, + label: kindLabel(kind, t), + })), + ) + + const languageOptions = computed(() => + (localProjectItem.value.langGroup || []) + .filter(language => language.text !== 'Other') + .map(language => language.text), + ) + + const repositoryFolderName = computed(() => { + const source = isRemote.value ? localProjectItem.value.remotePath : localProjectItem.value.path + return source + ? source.split(PATH_SPLIT_RE).filter(Boolean).at(-1) || '' + : '' + }) + + const effectivePath = computed(() => { + if (!isRemote.value) + return localProjectItem.value.path || '' + const host = localProjectItem.value.remoteHost?.trim() + const remotePath = localProjectItem.value.remotePath?.trim() + return host && remotePath ? `${host}:${remotePath}` : '' + }) + + const duplicatePath = computed(() => + !!effectivePath.value + && projectsStore.checkPathExistenceInProjects( + effectivePath.value, + isUpdateProject.value && initialProjectPath ? [initialProjectPath] : [], + ), + ) + + const sourceSuggestion = computed(() => { + const url = localProjectItem.value.fromUrl?.trim() + if (!url) + return '' + return extractGithubRepositoryName(url) + }) + + const canSave = computed(() => { + if (isRemote.value) { + return !!localProjectItem.value.remoteHost + && !!localProjectItem.value.remotePath + && !!localProjectItem.value.name + && !!localProjectItem.value.defaultOpen + } + return !!localProjectItem.value.path + && !!localProjectItem.value.name + && !!localProjectItem.value.mainLang + && !!localProjectItem.value.defaultOpen + }) + + const folderStatus = computed(() => + duplicatePath.value ? t('app.project_editor.fields.folder_duplicate') : '', + ) + + const mainLanguageStatus = computed(() => + visibleAnalyzing.value ? t('app.project_editor.analyzing_desc') : '', + ) + + const summaryItems = computed(() => [ + { label: t('app.project_editor.summary.kind'), value: kindLabel(localProjectItem.value.kind, t) }, + { label: t('app.project_editor.summary.editor'), value: localProjectItem.value.defaultOpen ? codeEditors[localProjectItem.value.defaultOpen]?.label : t('app.common.not_set') }, + { label: t('app.project_editor.summary.license'), value: shortLicense(localProjectItem.value.license as LicenseEnum, t) }, + ]) + + async function browseProjectPath() { + const selectedPaths = await window.api.openFolderDialog() + if (!selectedPaths[0]) + return + + localProjectItem.value.path = selectedPaths[0] + touchedFields.path = true + await refreshDetectedProjectMetadata() + } + + function handlePathBlur() { + markTouched('path') + void refreshDetectedProjectMetadata() + } + + function fillProjectName() { + if (repositoryFolderName.value) { + localProjectItem.value.name = repositoryFolderName.value + touchedFields.name = true + } + } + + function fillSourceName() { + if (sourceSuggestion.value) + localProjectItem.value.fromName = sourceSuggestion.value + } + + function markTouched(field: ProjectEditorFieldKey) { + touchedFields[field] = true + } + + function hasDetectedMetadata() { + return !!localProjectItem.value.mainLang && !!localProjectItem.value.defaultOpen + } + + function fillDefaultEditorFromLanguage(language: string | null | undefined) { + if (!language || localProjectItem.value.defaultOpen) + return + + localProjectItem.value.defaultOpen = editorLangGroupsStore.getEditorByLanguage(language) + } + + function resetDetectedProjectMetadata() { + localProjectItem.value.mainLang = null + localProjectItem.value.mainLangColor = null + localProjectItem.value.langGroup = [] + localProjectItem.value.defaultOpen = null + localProjectItem.value.license = LicenseEnum.NONE + } + + async function refreshDetectedProjectMetadata() { + const path = localProjectItem.value.path?.trim() + if (!path) + return + if (path === lastAnalyzedPath.value && hasDetectedMetadata()) + return + + if (!localProjectItem.value.name) + localProjectItem.value.name = repositoryFolderName.value + + resetDetectedProjectMetadata() + lastAnalyzedPath.value = path + + await Promise.all([ + analyzeProject(path), + detectLicense(path), + ]) + } + + async function analyzeProject(targetPath = localProjectItem.value.path) { + const path = targetPath + if (!path) + return + + analyzing.value = true + try { + const analyzer = new LanguageAnalyzer(path) + const success = await analyzer.analyze() + if (!success) + return + if (localProjectItem.value.path !== path) + return + + localProjectItem.value.mainLang = analyzer.mainLang + localProjectItem.value.mainLangColor = analyzer.mainLangColor + localProjectItem.value.langGroup = analyzer.langGroup + fillDefaultEditorFromLanguage(analyzer.mainLang) + } + finally { + analyzing.value = false + } + } + + function runAnalyzeProject() { + void analyzeProject() + } + + async function detectLicense(targetPath = localProjectItem.value.path) { + const path = targetPath + if (!path) + return + + detectingLicense.value = true + try { + const res = await window.api.readProjectLicense(path) + if (!res?.success || !res.snippet) + return + if (localProjectItem.value.path !== path) + return + + const { license, score } = detectLicenseBySnippet(res.snippet) + if (license && score > 0) + localProjectItem.value.license = license + } + finally { + detectingLicense.value = false + } + } + + function runDetectLicense() { + void detectLicense() + } + + function updateLanguage(language: string | null) { + localProjectItem.value.mainLang = language + const languageItem = localProjectItem.value.langGroup?.find(item => item.text === language) + localProjectItem.value.mainLangColor = languageItem?.color || null + fillDefaultEditorFromLanguage(language) + } + + async function hydrateLanguageMixFromCache() { + const path = localProjectItem.value.path + if (!path || localProjectItem.value.langGroup?.length) + return + + await projectScannerStore.loadProjectScannerData() + const cached = projectScannerStore.getScanCacheEntry(path) + if (!cached?.langGroup?.length) + return + if (localProjectItem.value.path !== path) + return + + localProjectItem.value.langGroup = cached.langGroup + localProjectItem.value.mainLang = localProjectItem.value.mainLang || cached.mainLang || cached.langGroup[0]?.text || null + localProjectItem.value.mainLangColor = localProjectItem.value.mainLangColor || cached.mainLangColor || cached.langGroup[0]?.color + fillDefaultEditorFromLanguage(localProjectItem.value.mainLang) + } + + function validateFields() { + touchedFields.name = true + touchedFields.defaultOpen = true + if (isRemote.value) { + touchedFields.remoteHost = true + touchedFields.remotePath = true + } + else { + touchedFields.path = true + touchedFields.mainLang = true + } + return canSave.value && !duplicatePath.value + } + + function toProject(): LocalProject { + const remote = isRemote.value + const remoteHost = localProjectItem.value.remoteHost?.trim() || undefined + const remotePath = localProjectItem.value.remotePath?.trim() || undefined + return { + appendTime: localProjectItem.value.appendTime || Date.now(), + lastOpenedAt: localProjectItem.value.lastOpenedAt ?? undefined, + path: remote ? effectivePath.value : localProjectItem.value.path!, + name: localProjectItem.value.name!, + group: localProjectItem.value.group || '', + kind: localProjectItem.value.kind, + fromUrl: localProjectItem.value.fromUrl || undefined, + fromName: localProjectItem.value.fromName || undefined, + mainLang: remote ? (localProjectItem.value.mainLang || '') : localProjectItem.value.mainLang!, + mainLangColor: localProjectItem.value.mainLangColor || undefined, + langGroup: localProjectItem.value.langGroup || [], + defaultOpen: localProjectItem.value.defaultOpen!, + license: localProjectItem.value.license !== LicenseEnum.NONE + ? localProjectItem.value.license as LicenseEnum + : undefined, + isTemporary: !!localProjectItem.value.isTemporary, + isPinned: !!localProjectItem.value.isPinned, + isExists: true, + isRemote: remote || undefined, + remoteHost: remote ? remoteHost : undefined, + remotePath: remote ? remotePath : undefined, + } + } + + async function saveProject() { + if (!validateFields()) + return + + const project = toProject() + if (isUpdateProject.value) + await projectsStore.updateProject(project.appendTime, project) + else + await projectsStore.addProject(project, true) + closeEditor() + } + + function closeEditor() { + initializeNewProjectState() + activatedView.value = ViewEnum.Home + } + + function updateProjectKind(kind: string) { + localProjectItem.value.kind = kind as ProjectKind + } + + function setProjectMode(mode: string) { + const remote = mode === 'remote' + if (remote === !!localProjectItem.value.isRemote) + return + + localProjectItem.value.isRemote = remote + if (remote) { + localProjectItem.value.path = null + if (localProjectItem.value.defaultOpen && !isVscodeHistoryScannerEditor(localProjectItem.value.defaultOpen)) + localProjectItem.value.defaultOpen = null + } + else { + localProjectItem.value.remoteHost = null + localProjectItem.value.remotePath = null + } + } + + function fieldInvalid(field: ProjectEditorFieldKey) { + return touchedFields[field] && !localProjectItem.value[field] + } + + watch( + () => localProjectItem.value.path, + (path, oldPath) => { + if (path === oldPath) + return + if (!path) + lastAnalyzedPath.value = '' + resetDetectedProjectMetadata() + }, + { flush: 'sync' }, + ) + + watch( + () => localProjectItem.value.mainLang, + language => updateLanguage(language), + ) + + onMounted(() => { + void hydrateLanguageMixFromCache() + }) + + return { + analyzing, + browseProjectPath, + canSave, + closeEditor, + detectingLicense, + duplicatePath, + effectivePath, + editorOptions, + fieldInvalid, + fillProjectName, + fillSourceName, + folderStatus, + handlePathBlur, + isRemote, + languageOptions, + licenseOptions, + mainLanguageStatus, + markTouched, + modeOptions, + projectKindOptions, + projectMode, + projectsStore, + repositoryFolderName, + runAnalyzeProject, + runDetectLicense, + saveProject, + setProjectMode, + sourceSuggestion, + summaryItems, + updateProjectKind, + visibleAnalyzing, + } +} diff --git a/src/views/SettingsView/SettingsView.vue b/src/views/SettingsView/SettingsView.vue index 7699939..0a29f10 100644 --- a/src/views/SettingsView/SettingsView.vue +++ b/src/views/SettingsView/SettingsView.vue @@ -225,22 +225,44 @@ onUnmounted(() => { - - - - + + + + - - {{ t('app.settings.title') }} + + + {{ t('app.settings.title') }} + - + { - + { - - + + - + - + - + - + @@ -298,34 +345,15 @@ onUnmounted(() => { diff --git a/src/views/SettingsView/components/SettingsRow.vue b/src/views/SettingsView/components/SettingsRow.vue index 5ff2301..8f56c3f 100644 --- a/src/views/SettingsView/components/SettingsRow.vue +++ b/src/views/SettingsView/components/SettingsRow.vue @@ -18,19 +18,33 @@ withDefaults(defineProps<{ - - - {{ title }} - + + + {{ title }} + {{ description }} - + @@ -43,10 +57,6 @@ withDefaults(defineProps<{ diff --git a/src/views/SettingsView/pages/Appearance.vue b/src/views/SettingsView/pages/Appearance.vue index 575b00f..00c8cc8 100644 --- a/src/views/SettingsView/pages/Appearance.vue +++ b/src/views/SettingsView/pages/Appearance.vue @@ -125,7 +125,10 @@ function themeColorButtonStyle(themeColor: ThemeColorEnum) { :model-value="appThemeColor" @update:model-value="selectThemeColor" > - + diff --git a/src/views/SettingsView/pages/AutoProjectScanner.vue b/src/views/SettingsView/pages/AutoProjectScanner.vue index 0ea6965..f303e87 100644 --- a/src/views/SettingsView/pages/AutoProjectScanner.vue +++ b/src/views/SettingsView/pages/AutoProjectScanner.vue @@ -1,739 +1,89 @@ - - - {{ t('app.settings.scanner.title') }} + + + + {{ t('app.settings.scanner.title') }} + - - - - {{ t('app.settings.scanner.sources.title') }} - {{ t('app.settings.scanner.sources.desc') }} - - - - - - {{ t('app.settings.scanner.roots.title') }} - {{ t('app.settings.scanner.roots.desc') }} - - - - - - - - - {{ t('app.settings.scanner.roots.add') }} - - {{ t('app.settings.scanner.roots.count', { count: settings.scanner.roots.length }) }} - - - - - {{ t('app.settings.scanner.roots.empty') }} - - - {{ root }} - - - - - - - - - - - {{ t('app.settings.scanner.ide.title') }} - {{ t('app.settings.scanner.ide.desc') }} - - - - - - - - {{ row.label }} - - - - - - - - - - - - - JetBrains - - - - - - - - - - - - - - - {{ t('app.settings.scanner.strategy.title') }} - {{ t('app.settings.scanner.strategy.desc') }} - - - - - {{ t('app.settings.scanner.root_open_mode.title') }} - {{ t('app.settings.scanner.root_open_mode.desc') }} - - - - - - - {{ t('app.settings.scanner.ide_open_mode.title') }} - {{ t('app.settings.scanner.ide_open_mode.desc') }} - - - - - - - {{ t('app.settings.scanner.specified_editor.title') }} - {{ t('app.settings.scanner.specified_editor.desc') }} - - - - - - - {{ t('app.settings.scanner.name_pattern.title') }} - {{ t('app.settings.scanner.name_pattern.desc') }} - - - - - - - {{ t('app.settings.scanner.history.title') }} - {{ t('app.settings.scanner.history.desc') }} - - - {{ t('app.settings.scanner.history.clear') }} - - - + + + + - - - - {{ t('app.common.cancel') }} - - - {{ t('app.common.clear') }} - - - + diff --git a/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerClearHistoryDialog.vue b/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerClearHistoryDialog.vue new file mode 100644 index 0000000..df8bea7 --- /dev/null +++ b/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerClearHistoryDialog.vue @@ -0,0 +1,36 @@ + + + + + + + {{ t('app.common.cancel') }} + + + {{ t('app.common.clear') }} + + + + diff --git a/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerSourcesPanel.vue b/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerSourcesPanel.vue new file mode 100644 index 0000000..685f099 --- /dev/null +++ b/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerSourcesPanel.vue @@ -0,0 +1,259 @@ + + + + + + {{ t('app.settings.scanner.sources.title') }} + {{ t('app.settings.scanner.sources.desc') }} + + + + + + {{ t('app.settings.scanner.roots.title') }} + {{ t('app.settings.scanner.roots.desc') }} + + + + + + + + + {{ t('app.settings.scanner.roots.add') }} + + + {{ t('app.settings.scanner.roots.count', { count: settings.scanner.roots.length }) }} + + + + + + {{ t('app.settings.scanner.roots.empty') }} + + + {{ root }} + + + + + + + + + + + {{ t('app.settings.scanner.ide.title') }} + {{ t('app.settings.scanner.ide.desc') }} + + + + + + + + {{ row.label }} + + + + + + + + + + + + + JetBrains + + + + + + + + + + + + + + diff --git a/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerStrategyPanel.vue b/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerStrategyPanel.vue new file mode 100644 index 0000000..00e0ce7 --- /dev/null +++ b/src/views/SettingsView/pages/AutoProjectScanner/components/ScannerStrategyPanel.vue @@ -0,0 +1,109 @@ + + + + + + {{ t('app.settings.scanner.strategy.title') }} + {{ t('app.settings.scanner.strategy.desc') }} + + + + + {{ t('app.settings.scanner.root_open_mode.title') }} + {{ t('app.settings.scanner.root_open_mode.desc') }} + + + + + + + {{ t('app.settings.scanner.ide_open_mode.title') }} + {{ t('app.settings.scanner.ide_open_mode.desc') }} + + + + + + + {{ t('app.settings.scanner.specified_editor.title') }} + {{ t('app.settings.scanner.specified_editor.desc') }} + + + + + + + {{ t('app.settings.scanner.name_pattern.title') }} + {{ t('app.settings.scanner.name_pattern.desc') }} + + + + + + + {{ t('app.settings.scanner.history.title') }} + {{ t('app.settings.scanner.history.desc') }} + + + {{ t('app.settings.scanner.history.clear') }} + + + + diff --git a/src/views/SettingsView/pages/AutoProjectScanner/composables/useAutoProjectScanner.ts b/src/views/SettingsView/pages/AutoProjectScanner/composables/useAutoProjectScanner.ts new file mode 100644 index 0000000..c6d3485 --- /dev/null +++ b/src/views/SettingsView/pages/AutoProjectScanner/composables/useAutoProjectScanner.ts @@ -0,0 +1,337 @@ +import { useI18n } from 'vue-i18n' + +import type { CliHistoryScannerEditor, CodeEditorEnum, VscodeHistoryScannerEditor } from '~/constants/codeEditor' +import { + codeEditorOrder, + codeEditors, + isCliHistoryScannerEditor, + isVscodeHistoryScannerEditor, + projectHistoryScannerEditors, + scannerEditorLabels, +} from '~/constants/codeEditor' +import { useProjectScannerStore } from '~/stores/projectScannerStore' +import type { CliEditorScannerConfig, RecentEditorScannerConfig } from '~/stores/settingsStore' +import { useSettingsStore } from '~/stores/settingsStore' +import { useDelayedBusyKeys } from '~/utils/useDelayedBusy' + +export type ScannerSourceKey = VscodeHistoryScannerEditor | CliHistoryScannerEditor +export type ScannerDetectKey = 'jetbrains' | ScannerSourceKey + +export interface VscodeScannerSourceRow { + key: VscodeHistoryScannerEditor + kind: 'vscode-history' + label: string + config: RecentEditorScannerConfig +} + +export interface CliScannerSourceRow { + key: CliHistoryScannerEditor + kind: 'cli-history' + label: string + config: CliEditorScannerConfig +} + +export type ScannerSourceRow = VscodeScannerSourceRow | CliScannerSourceRow + +export function useAutoProjectScanner() { + const settings = useSettingsStore() + const projectScannerStore = useProjectScannerStore() + const { t } = useI18n() + const showClearConfirm = ref(false) + const rootRowRefs = new Map() + const detectingScannerKeys = ref>(new Set()) + const { + visibleKeys: visibleDetectingScannerKeys, + setKeyBusy: setVisibleDetectingScannerKey, + } = useDelayedBusyKeys({ delay: 220, minDuration: 320 }) + + const editorOptions = computed>(() => + codeEditorOrder.map(editor => ({ + value: editor, + label: codeEditors[editor].label, + })), + ) + + const rootOpenModeOptions = computed(() => [ + { + value: 'language', + label: t('app.settings.scanner.open_mode.language'), + tooltip: t('app.settings.scanner.open_mode_help.language'), + }, + { + value: 'specified', + label: t('app.settings.scanner.open_mode.specified'), + tooltip: t('app.settings.scanner.open_mode_help.specified'), + }, + ]) + + const ideOpenModeOptions = computed(() => [ + { + value: 'source', + label: t('app.settings.scanner.open_mode.source'), + tooltip: t('app.settings.scanner.open_mode_help.source'), + }, + { + value: 'language', + label: t('app.settings.scanner.open_mode.language'), + tooltip: t('app.settings.scanner.open_mode_help.ide_language'), + }, + { + value: 'specified', + label: t('app.settings.scanner.open_mode.specified'), + tooltip: t('app.settings.scanner.open_mode_help.specified'), + }, + ]) + + const specifiedEditorEnabled = computed(() => + settings.scanner.rootOpenMode === 'specified' || settings.scanner.ideOpenMode === 'specified', + ) + + const scannerSourceRows = computed(() => + projectHistoryScannerEditors + .map((editor) => { + const label = scannerEditorLabels[editor] || codeEditors[editor].label + if (isVscodeHistoryScannerEditor(editor)) { + return { + key: editor, + kind: 'vscode-history', + label, + config: settings.scanner.recentEditors[editor], + } + } + if (isCliHistoryScannerEditor(editor)) { + return { + key: editor, + kind: 'cli-history', + label, + config: settings.scanner.cliEditors[editor], + } + } + return null + }) + .filter((row): row is ScannerSourceRow => row !== null), + ) + + async function addProjectRoot() { + if (!settings.scanner.rootsEnabled) + return + + const selectedPaths = await window.api.openFolderDialog() + if (!selectedPaths.length) + return + + const roots = Array.from(new Set([...settings.scanner.roots, ...selectedPaths])).sort() + settings.scanner.roots.splice(0, settings.scanner.roots.length, ...roots) + settings.scanner.rootsEnabled = true + } + + function removeRoot(root: string) { + const index = settings.scanner.roots.indexOf(root) + if (index >= 0) + settings.scanner.roots.splice(index, 1) + } + + function setRootRowRef(root: string, element: unknown) { + if (element instanceof HTMLElement) { + rootRowRefs.set(root, element) + return + } + + rootRowRefs.delete(root) + } + + function focusRootRow(root: string | undefined, preferRemoveButton = false) { + if (!root) + return + + const row = rootRowRefs.get(root) + if (!row) + return + + const target = preferRemoveButton + ? row.querySelector('[data-root-action="remove"]:not(:disabled)') || row + : row + + target.focus({ preventScroll: true }) + target.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + + function focusRootRowByOffset(root: string, offset: number, preferRemoveButton = false) { + const roots = settings.scanner.roots + const currentIndex = roots.indexOf(root) + if (currentIndex < 0) + return + + const targetIndex = Math.max(0, Math.min(roots.length - 1, currentIndex + offset)) + focusRootRow(roots[targetIndex], preferRemoveButton) + } + + function focusRootRowAtEdge(edge: 'start' | 'end', preferRemoveButton = false) { + focusRootRow(edge === 'start' ? settings.scanner.roots[0] : settings.scanner.roots.at(-1), preferRemoveButton) + } + + async function removeFocusedRoot(root: string) { + const roots = settings.scanner.roots + const index = roots.indexOf(root) + if (index < 0) + return + + const nextRoot = roots[index + 1] || roots[index - 1] + removeRoot(root) + await nextTick() + focusRootRow(nextRoot) + } + + function handleRootRowKeydown(root: string, event: KeyboardEvent) { + if (!settings.scanner.rootsEnabled || event.defaultPrevented) + return + + const preferRemoveButton = event.target instanceof HTMLElement + && !!event.target.closest('[data-root-action="remove"]') + + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + event.stopPropagation() + focusRootRowByOffset(root, -1, preferRemoveButton) + break + case 'ArrowDown': + event.preventDefault() + event.stopPropagation() + focusRootRowByOffset(root, 1, preferRemoveButton) + break + case 'Home': + event.preventDefault() + event.stopPropagation() + focusRootRowAtEdge('start', preferRemoveButton) + break + case 'End': + event.preventDefault() + event.stopPropagation() + focusRootRowAtEdge('end', preferRemoveButton) + break + case 'Delete': + event.preventDefault() + event.stopPropagation() + void removeFocusedRoot(root) + break + } + } + + async function browseJetbrainsRoot() { + if (!settings.scanner.ideEnabled || !settings.scanner.jetbrains.enabled) + return + + const selectedPaths = await window.api.openFolderDialog() + if (selectedPaths[0]) + settings.scanner.jetbrains.configRootPath = selectedPaths[0] + } + + function getScannerSourcePath(row: ScannerSourceRow) { + return row.kind === 'vscode-history' + ? row.config.stateDbPath + : row.config.historyRootPath + } + + function setScannerSourcePath(row: ScannerSourceRow, value: string) { + if (row.kind === 'vscode-history') { + row.config.stateDbPath = value + return + } + row.config.historyRootPath = value + } + + function handleScannerSourcePathInput(row: ScannerSourceRow, event: Event) { + const target = event.target + if (target instanceof HTMLInputElement) + setScannerSourcePath(row, target.value) + } + + function getScannerSourcePlaceholder(row: ScannerSourceRow) { + return row.kind === 'vscode-history' + ? t('app.settings.scanner.recent_editor.placeholder', { editor: row.label }) + : t('app.settings.scanner.cli_editor.placeholder', { editor: row.label }) + } + + async function browseScannerSource(row: ScannerSourceRow) { + if (!settings.scanner.ideEnabled || !row.config.enabled) + return + + const selectedPaths = row.kind === 'vscode-history' + ? await window.api.openFileDialog({ + fileTypes: [{ name: t('app.dialog.file_types.editor_history_database'), extensions: ['vscdb'] }], + }) + : await window.api.openFolderDialog() + + if (selectedPaths[0]) + setScannerSourcePath(row, selectedPaths[0]) + } + + async function detectJetbrainsRoot() { + if (!settings.scanner.ideEnabled || !settings.scanner.jetbrains.enabled || detectingScannerKeys.value.has('jetbrains')) + return + + detectingScannerKeys.value = new Set([...detectingScannerKeys.value, 'jetbrains']) + setVisibleDetectingScannerKey('jetbrains', true) + try { + const detectedPath = await window.api.detectJetBrainsConfigRootPath() + if (detectedPath) + settings.scanner.jetbrains.configRootPath = detectedPath + } + finally { + const next = new Set(detectingScannerKeys.value) + next.delete('jetbrains') + detectingScannerKeys.value = next + setVisibleDetectingScannerKey('jetbrains', false) + } + } + + async function detectScannerSource(row: ScannerSourceRow) { + if (!settings.scanner.ideEnabled || !row.config.enabled || detectingScannerKeys.value.has(row.key)) + return + + detectingScannerKeys.value = new Set([...detectingScannerKeys.value, row.key]) + setVisibleDetectingScannerKey(row.key, true) + try { + const detectedPath = row.kind === 'vscode-history' + ? await window.api.detectRecentEditorStateDbPath(row.key) + : await window.api.detectCliHistoryRootPath(row.key) + if (detectedPath) + setScannerSourcePath(row, detectedPath) + } + finally { + const next = new Set(detectingScannerKeys.value) + next.delete(row.key) + detectingScannerKeys.value = next + setVisibleDetectingScannerKey(row.key, false) + } + } + + function clearScanHistory() { + projectScannerStore.clearProjectScannerData() + showClearConfirm.value = false + } + + return { + addProjectRoot, + browseJetbrainsRoot, + browseScannerSource, + clearScanHistory, + detectJetbrainsRoot, + detectingScannerKeys, + detectScannerSource, + editorOptions, + getScannerSourcePath, + getScannerSourcePlaceholder, + handleRootRowKeydown, + handleScannerSourcePathInput, + ideOpenModeOptions, + removeRoot, + rootOpenModeOptions, + scannerSourceRows, + setRootRowRef, + showClearConfirm, + specifiedEditorEnabled, + visibleDetectingScannerKeys, + } +} diff --git a/src/views/SettingsView/pages/Data.vue b/src/views/SettingsView/pages/Data.vue index 771b5cf..e94c86e 100644 --- a/src/views/SettingsView/pages/Data.vue +++ b/src/views/SettingsView/pages/Data.vue @@ -222,7 +222,10 @@ async function pullWebDavData() { - + {{ t('app.settings.data.projects.import') }} @@ -238,50 +241,57 @@ async function pullWebDavData() { :title="t('app.settings.data.webdav.title')" align="start" > - - - - {{ t('app.settings.data.webdav.server') }} + + + + {{ t('app.settings.data.webdav.server') }} - - {{ t('app.settings.data.webdav.remote_path') }} + + {{ t('app.settings.data.webdav.remote_path') }} - - {{ t('app.settings.data.webdav.account') }} + + {{ t('app.settings.data.webdav.account') }} - - {{ t('app.settings.data.webdav.password') }} + + {{ t('app.settings.data.webdav.password') }} - + @@ -321,80 +332,3 @@ async function pullWebDavData() { - - diff --git a/src/views/SettingsView/pages/Ides.vue b/src/views/SettingsView/pages/Ides.vue index 2ff7e2e..c3f490a 100644 --- a/src/views/SettingsView/pages/Ides.vue +++ b/src/views/SettingsView/pages/Ides.vue @@ -183,30 +183,38 @@ function quoteCommand(command: string) { - - - {{ t('app.settings.editors.title') }} + + + + {{ t('app.settings.editors.title') }} + - - - {{ t('app.settings.editors.terminal_launcher') }} + + + + {{ t('app.settings.editors.terminal_launcher') }} + - - - - - {{ t('app.settings.editors.terminal_command') }} - + + + + + {{ t('app.settings.editors.terminal_command') }} + {{ terminalCommandHint }} - + - {{ group.label }} + + {{ group.label }} + - - - - {{ option.label }} - {{ editorDescription(option) || editorNames(option) }} + + + + {{ option.label }} + {{ editorDescription(option) || editorNames(option) }} - - + + - {{ t('app.settings.editors.terminal') }} + {{ t('app.settings.editors.terminal') }} - +
- {{ folderStatus }} -
- {{ mainLanguageStatus }} -
+ {{ folderStatus }} +
+ {{ mainLanguageStatus }} +
@@ -43,10 +57,6 @@ withDefaults(defineProps<{ diff --git a/src/views/SettingsView/pages/Appearance.vue b/src/views/SettingsView/pages/Appearance.vue index 575b00f..00c8cc8 100644 --- a/src/views/SettingsView/pages/Appearance.vue +++ b/src/views/SettingsView/pages/Appearance.vue @@ -125,7 +125,10 @@ function themeColorButtonStyle(themeColor: ThemeColorEnum) { :model-value="appThemeColor" @update:model-value="selectThemeColor" > -
@@ -321,80 +332,3 @@ async function pullWebDavData() { - - diff --git a/src/views/SettingsView/pages/Ides.vue b/src/views/SettingsView/pages/Ides.vue index 2ff7e2e..c3f490a 100644 --- a/src/views/SettingsView/pages/Ides.vue +++ b/src/views/SettingsView/pages/Ides.vue @@ -183,30 +183,38 @@ function quoteCommand(command: string) { - - - {{ t('app.settings.editors.title') }} + + + + {{ t('app.settings.editors.title') }} + - - - {{ t('app.settings.editors.terminal_launcher') }} + + + + {{ t('app.settings.editors.terminal_launcher') }} + - - - - - {{ t('app.settings.editors.terminal_command') }} - + + + + + {{ t('app.settings.editors.terminal_command') }} + {{ terminalCommandHint }} - + - {{ group.label }} + + {{ group.label }} + - - - - {{ option.label }} - {{ editorDescription(option) || editorNames(option) }} + + + + {{ option.label }} + {{ editorDescription(option) || editorNames(option) }} - - + + - {{ t('app.settings.editors.terminal') }} + {{ t('app.settings.editors.terminal') }} - +