From 196219f19423104317e1233b418231e3e3b45ee9 Mon Sep 17 00:00:00 2001 From: MidnightCrowing <110297461+MidnightCrowing@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:05:30 +0800 Subject: [PATCH 1/7] refactor(home): split HomeView into focused modules --- src/views/HomeView/HomeView.vue | 2146 ++--------------- src/views/HomeView/LanguageFilterSelect.vue | 67 +- .../HomeView/components/HomeEmptyState.vue | 15 + .../HomeView/components/HomeFilterBar.vue | 184 ++ src/views/HomeView/components/HomeHeader.vue | 114 + src/views/HomeView/components/ProjectCard.vue | 149 ++ src/views/HomeView/components/ProjectGrid.vue | 81 + src/views/HomeView/components/ProjectList.vue | 131 + .../HomeView/components/ProjectListRow.vue | 172 ++ .../components/project/ProjectActions.vue | 238 ++ .../components/project/ProjectEditorChip.vue | 34 + .../components/project/ProjectMeta.vue | 59 + .../components/project/ProjectSource.vue | 83 + .../components/project/ProjectTitle.vue | 137 ++ .../HomeView/composables/useProjectActions.ts | 420 ++++ .../useProjectKeyboardNavigation.ts | 390 +++ .../HomeView/composables/useProjectRefs.ts | 56 + src/views/HomeView/utils/homeLayoutStorage.ts | 21 + src/views/HomeView/utils/projectFormatters.ts | 75 + src/views/HomeView/utils/projectSource.ts | 70 + 20 files changed, 2611 insertions(+), 2031 deletions(-) create mode 100644 src/views/HomeView/components/HomeEmptyState.vue create mode 100644 src/views/HomeView/components/HomeFilterBar.vue create mode 100644 src/views/HomeView/components/HomeHeader.vue create mode 100644 src/views/HomeView/components/ProjectCard.vue create mode 100644 src/views/HomeView/components/ProjectGrid.vue create mode 100644 src/views/HomeView/components/ProjectList.vue create mode 100644 src/views/HomeView/components/ProjectListRow.vue create mode 100644 src/views/HomeView/components/project/ProjectActions.vue create mode 100644 src/views/HomeView/components/project/ProjectEditorChip.vue create mode 100644 src/views/HomeView/components/project/ProjectMeta.vue create mode 100644 src/views/HomeView/components/project/ProjectSource.vue create mode 100644 src/views/HomeView/components/project/ProjectTitle.vue create mode 100644 src/views/HomeView/composables/useProjectActions.ts create mode 100644 src/views/HomeView/composables/useProjectKeyboardNavigation.ts create mode 100644 src/views/HomeView/composables/useProjectRefs.ts create mode 100644 src/views/HomeView/utils/homeLayoutStorage.ts create mode 100644 src/views/HomeView/utils/projectFormatters.ts create mode 100644 src/views/HomeView/utils/projectSource.ts diff --git a/src/views/HomeView/HomeView.vue b/src/views/HomeView/HomeView.vue index 28ed12e..8a854b8 100644 --- a/src/views/HomeView/HomeView.vue +++ b/src/views/HomeView/HomeView.vue @@ -2,50 +2,30 @@ import { useI18n } from 'vue-i18n' import mainLangPop from '~/components/LanguagePop/LanguagePop.vue' -import { showPop as showLanguagePop } from '~/components/LanguagePop/LanguagePopProvider' -import { setRemoveAnimationCallback, showRemoveDialog } from '~/components/RemoveProjectDialog' -import { showToast } from '~/components/ui/toast' -import UiActionMenu from '~/components/ui/UiActionMenu.vue' -import UiScrollArea from '~/components/ui/UiScrollArea.vue' -import UiSegmentedControl from '~/components/ui/UiSegmentedControl.vue' -import UiSelect from '~/components/ui/UiSelect.vue' -import { SettingPageEnum, ThemeEnum, ViewEnum } from '~/constants/appEnums' +import { setRemoveAnimationCallback } from '~/components/RemoveProjectDialog' +import type { ViewEnum } from '~/constants/appEnums' +import { ThemeEnum } from '~/constants/appEnums' import type { CodeEditorEnum } from '~/constants/codeEditor' -import { codeEditors, isCodeEditor, isVscodeHistoryScannerEditor } from '~/constants/codeEditor' -import { LicenseEnum } from '~/constants/license' -import type { LocalProject } from '~/constants/localProject' -import { ProjectKind } from '~/constants/localProject' -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 { - initializeNewProjectState, - initializeUpdateProjectState, -} from '~/views/ProjectEditorView' -import { activatedPage } from '~/views/SettingsView' +import HomeEmptyState from './components/HomeEmptyState.vue' +import HomeFilterBar from './components/HomeFilterBar.vue' +import HomeHeader from './components/HomeHeader.vue' +import ProjectGrid from './components/ProjectGrid.vue' +import ProjectList from './components/ProjectList.vue' import { useDisplayPathCache } from './composables/useDisplayPathCache' +import { useProjectActions } from './composables/useProjectActions' import { useProjectFilters } from './composables/useProjectFilters' +import { useProjectKeyboardNavigation } from './composables/useProjectKeyboardNavigation' import { useProjectMenus } from './composables/useProjectMenus' import { useProjectNavigation } from './composables/useProjectNavigation' import { useProjectOpenState } from './composables/useProjectOpenState' +import { useProjectRefs } from './composables/useProjectRefs' import { useProjectSearch } from './composables/useProjectSearch' import { useVirtualScroll } from './composables/useVirtualScroll' -import { - DEFAULT_EDITOR_ACTION_PREFIX, - DEFAULT_EDITOR_AUTO_ACTION, - GITHUB_SOURCE_RE, - HOME_LAYOUT_STORAGE_KEY, - MIN_SYNC_BUSY_MS, - OPEN_WITH_ACTION_PREFIX, - PROJECT_ACTION_KEYS, - SCAN_RESULT_TOAST_THRESHOLD_MS, -} from './constants' -import LanguageFilterSelect from './LanguageFilterSelect.vue' -import type { LayoutMode, ProjectActionKey, ScrollAreaRef } from './types' +import type { LayoutMode, ScrollAreaRef } from './types' +import { persistLayoutMode, readStoredLayoutMode } from './utils/homeLayoutStorage' defineOptions({ name: 'Home', @@ -53,7 +33,6 @@ defineOptions({ const projectsStore = useProjectsStore() const settingsStore = useSettingsStore() -const editorLangGroupsStore = useEditorLangGroupsStore() // Composables const pathCache = useDisplayPathCache() @@ -80,15 +59,8 @@ const { const openState = useProjectOpenState() const { - markProjectOpening, - finishProjectOpening, - clearProjectOpening, isProjectOpening, - markTerminalOpening, - finishTerminalOpening, - clearTerminalOpening, isTerminalOpening, - clearProjectFromMaps: clearOpenStateFromMaps, } = openState const layoutMode = ref(readStoredLayoutMode()) @@ -109,18 +81,18 @@ function editorHasLaunchCommand(editor: CodeEditorEnum) { const projectMenus = useProjectMenus({ t, editorHasLaunchCommand }) const { projectMenuItems, - isSourceProject, - projectSourceUrl, } = projectMenus -// 手动同步的本地状态(带最短可见时长防闪烁);与服务层共享的 -// scannerBusy 合成,让启动自动扫描也能点亮同步按钮的进行中状态。 -const manualSyncing = ref(false) -const syncing = computed(() => manualSyncing.value || scannerBusy.value) -const copyFeedback = ref>({}) -const copyFeedbackTimers = new Map() -const projectItemRefs = new Map() -const projectActionRefs = new Map() +const projectRefs = useProjectRefs() +const { + clearProjectRefs, + deleteProjectRefs, + playProjectRemoveAnimation, + projectActionRefs, + projectItemRefs, + setProjectActionsRef, + setProjectItemRef, +} = projectRefs const listScrollAreaRef = ref(null) const cardsScrollAreaRef = ref(null) @@ -135,754 +107,57 @@ const { restoreHomeScrollPosition, } = projectNavigation -function readStoredLayoutMode(): LayoutMode { - try { - const value = window.localStorage.getItem(HOME_LAYOUT_STORAGE_KEY) - return value === 'grid' || value === 'list' ? value : 'list' - } - catch { - return 'list' - } -} - -function persistLayoutMode(value: LayoutMode) { - try { - window.localStorage.setItem(HOME_LAYOUT_STORAGE_KEY, value) - } - catch { - // Ignore storage errors. The layout toggle still works for the current session. - } -} +const projectActions = useProjectActions({ + deleteProjectRefs, + navigateFromHome, + openState, +}) +const { + addProject, + clearCopyFeedbackTimers, + clearProjectFromMaps, + copyFeedback, + copyProjectPath, + editProject, + handleProjectMenuSelect, + openInExplorer, + openInTerminal, + openProject, + openProjectSource, + openSettings, + showLanguage, + syncing, + syncProjects, +} = projectActions const layoutOptions = computed>(() => [ { value: 'list', label: t('app.home.layout.list'), icon: 'i-lucide:list' }, { value: 'grid', label: t('app.home.layout.grid'), icon: 'i-lucide:layout-grid' }, ]) -function addProject() { - initializeNewProjectState() - navigateFromHome(ViewEnum.ProjectEditor) -} - -function editProject(project: LocalProject) { - initializeUpdateProjectState(project) - 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 - - // 远程仅支持 VS Code 系(Remote-SSH) - 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) { - projectItemRefs.delete(projectId) - projectActionRefs.delete(projectId) - - const copyTimer = copyFeedbackTimers.get(projectId) - if (copyTimer) { - window.clearTimeout(copyTimer) - copyFeedbackTimers.delete(projectId) - } - - // 委托给 composable 清理打开状态 - clearOpenStateFromMaps(projectId) -} - -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) -} - -async function focusProject(project: LocalProject | undefined) { - if (!project?.isExists) - return - - await ensureProjectRendered(project) - - const element = 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 = 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 = 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 = projectItemRefs.values().next().value - if (!(firstElement instanceof HTMLElement)) - return 1 - - const firstTop = firstElement.getBoundingClientRect().top - let columnCount = 0 - - for (const element of 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 = 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 - } -} - -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 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 copyButtonTitle(project: LocalProject) { - const status = copyFeedback.value[project.appendTime] - if (status === 'success') - return t('app.home.actions.copied') - if (status === 'error') - return t('app.home.actions.copy_failed') - return t('app.home.actions.copy_path') -} - -function copyButtonIcon(project: LocalProject) { - const status = copyFeedback.value[project.appendTime] - if (status === 'success') - return 'i-lucide:check' - if (status === 'error') - return 'i-lucide:x' - return 'i-lucide:copy' -} - -function copyButtonClass(project: LocalProject) { - const status = copyFeedback.value[project.appendTime] - return { - 'copy-success': status === 'success', - 'copy-error': status === 'error', - } -} - -function openProjectButtonTitle(project: LocalProject) { - return isProjectOpening(project) - ? t('app.home.actions.opening_project') - : t('app.home.actions.open_project') -} - -function openProjectButtonAriaLabel(project: LocalProject) { - return isProjectOpening(project) - ? t('app.home.actions.opening_named', { name: project.name }) - : t('app.home.actions.open_named', { name: project.name }) -} - -function openProjectButtonIcon(project: LocalProject) { - return isProjectOpening(project) - ? 'i-lucide:loader-circle spinning' - : 'i-lucide:external-link' -} - -function terminalButtonTitle(project: LocalProject) { - return isTerminalOpening(project) - ? t('app.home.actions.opening_terminal') - : t('app.home.actions.open_terminal') -} - -function terminalButtonAriaLabel(project: LocalProject) { - return isTerminalOpening(project) - ? t('app.home.actions.opening_terminal_named', { name: project.name }) - : t('app.home.actions.open_terminal') -} - -function terminalButtonIcon(project: LocalProject) { - return isTerminalOpening(project) - ? 'i-lucide:loader-circle spinning' - : 'i-lucide:square-terminal' -} - -function projectSourceExternalUrl(project: LocalProject) { - const sourceUrl = projectSourceUrl(project) - if (!sourceUrl) - return '' - - const githubName = sourceUrl.match(GITHUB_SOURCE_RE)?.[1] - if (githubName) - return `https://github.com/${githubName.replace(/\.git$/, '')}` - - if (/^https?:\/\//i.test(sourceUrl)) - return sourceUrl - - return `https://${sourceUrl}` -} - -function sourceNameFromUrl(url: string) { - const githubName = url.match(GITHUB_SOURCE_RE)?.[1] - if (githubName) - return githubName - - try { - const parsedUrl = new URL(url) - const pathParts = parsedUrl.pathname.split('/').filter(Boolean) - return pathParts.length >= 2 - ? pathParts.slice(-2).join('/') - : parsedUrl.hostname - } - catch { - return url - } -} - -function projectSourceName(project: LocalProject) { - const sourceUrl = projectSourceUrl(project) - return isSourceProject(project) - ? project.fromName?.trim() || (sourceUrl ? sourceNameFromUrl(sourceUrl) : '') - : '' -} - -function hasProjectSource(project: LocalProject) { - return !!projectSourceName(project) -} - -function projectSourcePrefix(project: LocalProject) { - return project.kind === ProjectKind.FORK - ? t('app.home.source.forked_from') - : t('app.home.source.cloned_from') +function setListScrollAreaRef(element: unknown) { + listScrollAreaRef.value = element as ScrollAreaRef | null } -function projectSourceTitle(project: LocalProject) { - const sourceName = projectSourceName(project) - const sourceUrl = projectSourceUrl(project) - if (sourceName && sourceUrl && sourceName !== sourceUrl) - return `${projectSourcePrefix(project)} ${sourceName}\n${sourceUrl}` - return sourceName ? `${projectSourcePrefix(project)} ${sourceName}` : sourceUrl +function setCardsScrollAreaRef(element: unknown) { + cardsScrollAreaRef.value = element as ScrollAreaRef | null } -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': - openProjectSource(project) - break - case 'open': - void openProject(project) - break - case 'explorer': - openInExplorer(project) - break - case 'terminal': - void openInTerminal(project) - break - case 'copy': - void copyProjectPath(project) - break - case 'edit': - editProject(project) - break - case 'remove': - showRemoveDialog(project) - break - } -} +const keyboardNavigation = useProjectKeyboardNavigation({ + filteredProjects, + layoutMode, + refs: { projectActionRefs, projectItemRefs }, + ensureProjectRendered, + openProject, + editProject, + copyProjectPath, +}) +const { + actionButtonTabIndex, + handleProjectActionsKeydown, + handleProjectContainerKeydown, + isProjectActionDisabled, +} = keyboardNavigation onActivated(() => { restoreHomeScrollPosition() @@ -896,102 +171,14 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener('project-removed', handleProjectRemoved as EventListener) setRemoveAnimationCallback(null) - copyFeedbackTimers.forEach(timer => window.clearTimeout(timer)) - copyFeedbackTimers.clear() - projectItemRefs.clear() - projectActionRefs.clear() + clearCopyFeedbackTimers() + clearProjectRefs() }) function handleProjectRemoved(event: CustomEvent<{ projectId: number }>) { clearProjectFromMaps(event.detail.projectId) } -async function playProjectRemoveAnimation(projectId: number): Promise { - const element = projectItemRefs.get(projectId) - if (!element) { - return - } - - const htmlElement = element as HTMLElement - htmlElement.style.transition = 'opacity 180ms ease-out, transform 180ms ease-out' - htmlElement.style.opacity = '0' - htmlElement.style.transform = 'translateX(-12px)' - - await new Promise(resolve => setTimeout(resolve, 180)) -} - -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) -} - watch( renderedProjects, (projects) => { @@ -1006,89 +193,6 @@ watch( { immediate: true }, ) -function openSettings() { - navigateFromHome(ViewEnum.Settings) -} - -function openEditorSettings() { - activatedPage.value = SettingPageEnum.Ides - 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, - }, - }) -} - -function kindLabel(kind: ProjectKind) { - switch (kind) { - case ProjectKind.FORK: - return t('app.project_kind.fork') - case ProjectKind.CLONE: - return t('app.project_kind.clone') - case ProjectKind.MINE: - default: - return t('app.project_kind.mine') - } -} - -function kindClass(kind: ProjectKind) { - switch (kind) { - case ProjectKind.FORK: - return 'kind-fork' - case ProjectKind.CLONE: - return 'kind-clone' - case ProjectKind.MINE: - default: - return 'kind-mine' - } -} - -function shortLicense(license?: LicenseEnum) { - if (!license || license === LicenseEnum.NONE) - return t('app.license.none') - if (license === LicenseEnum.MIT) - return 'MIT' - if (license === LicenseEnum.APACHE_2_0) - return 'Apache-2.0' - if (license === LicenseEnum.GPLV3) - return 'GPL-3.0' - if (license === LicenseEnum.GPLV2) - return 'GPL-2.0' - if (license === LicenseEnum.OTHER) - return t('app.license.other') - return license.replace(' License', '').replace('GNU ', '') -} - -function editorIconClasses(editor?: keyof typeof codeEditors | null) { - if (!editor) - return [] - const option = codeEditors[editor] - return ['ide-icon', option?.icon, { 'monochrome-editor-icon': option?.monochromeIcon }] -} - -function formatTime(timestamp: number) { - return new Intl.DateTimeFormat(locale.value, { - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).format(new Date(timestamp)) -} - -function formatLastOpened(project: LocalProject) { - return project.lastOpenedAt - ? formatTime(project.lastOpenedAt) - : t('app.home.table.never_opened') -} - function updateLayoutMode(value: string) { layoutMode.value = value as LayoutMode } @@ -1100,1043 +204,111 @@ watch(layoutMode, () => { - diff --git a/src/views/HomeView/LanguageFilterSelect.vue b/src/views/HomeView/LanguageFilterSelect.vue index 5423461..d16c614 100644 --- a/src/views/HomeView/LanguageFilterSelect.vue +++ b/src/views/HomeView/LanguageFilterSelect.vue @@ -62,22 +62,28 @@ function colorDotStyle(color?: string) { :aria-label="triggerLabel" > - + - + {{ selectedOption?.label || placeholder }} - + {{ selectedOption.count }} - + @@ -98,18 +104,24 @@ function colorDotStyle(color?: string) { :value="option.value" > - + - + - {{ option.label }} + {{ option.label }} - + {{ option.count }} @@ -119,36 +131,3 @@ function colorDotStyle(color?: string) { - - 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 @@ + + + diff --git a/src/views/HomeView/components/HomeFilterBar.vue b/src/views/HomeView/components/HomeFilterBar.vue new file mode 100644 index 0000000..ca0baa3 --- /dev/null +++ b/src/views/HomeView/components/HomeFilterBar.vue @@ -0,0 +1,184 @@ + + + + + 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 @@ + + + + + diff --git a/src/views/HomeView/components/ProjectCard.vue b/src/views/HomeView/components/ProjectCard.vue new file mode 100644 index 0000000..b883051 --- /dev/null +++ b/src/views/HomeView/components/ProjectCard.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/src/views/HomeView/components/ProjectGrid.vue b/src/views/HomeView/components/ProjectGrid.vue new file mode 100644 index 0000000..69beb06 --- /dev/null +++ b/src/views/HomeView/components/ProjectGrid.vue @@ -0,0 +1,81 @@ + + + + + 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 @@ + + + + + diff --git a/src/views/HomeView/components/ProjectListRow.vue b/src/views/HomeView/components/ProjectListRow.vue new file mode 100644 index 0000000..4e5a6d6 --- /dev/null +++ b/src/views/HomeView/components/ProjectListRow.vue @@ -0,0 +1,172 @@ + + + + + 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 @@ + + + + + diff --git a/src/views/HomeView/components/project/ProjectEditorChip.vue b/src/views/HomeView/components/project/ProjectEditorChip.vue new file mode 100644 index 0000000..e657ec5 --- /dev/null +++ b/src/views/HomeView/components/project/ProjectEditorChip.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/views/HomeView/components/project/ProjectMeta.vue b/src/views/HomeView/components/project/ProjectMeta.vue new file mode 100644 index 0000000..7eb54cf --- /dev/null +++ b/src/views/HomeView/components/project/ProjectMeta.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/views/HomeView/components/project/ProjectSource.vue b/src/views/HomeView/components/project/ProjectSource.vue new file mode 100644 index 0000000..0f2dfe2 --- /dev/null +++ b/src/views/HomeView/components/project/ProjectSource.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/views/HomeView/components/project/ProjectTitle.vue b/src/views/HomeView/components/project/ProjectTitle.vue new file mode 100644 index 0000000..c276002 --- /dev/null +++ b/src/views/HomeView/components/project/ProjectTitle.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/views/HomeView/composables/useProjectActions.ts b/src/views/HomeView/composables/useProjectActions.ts new file mode 100644 index 0000000..2b75532 --- /dev/null +++ b/src/views/HomeView/composables/useProjectActions.ts @@ -0,0 +1,420 @@ +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 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 { + initializeNewProjectState, + initializeUpdateProjectState, +} from '~/views/ProjectEditorView' +import { activatedPage } from '~/views/SettingsView' + +import { + DEFAULT_EDITOR_ACTION_PREFIX, + DEFAULT_EDITOR_AUTO_ACTION, + MIN_SYNC_BUSY_MS, + OPEN_WITH_ACTION_PREFIX, + SCAN_RESULT_TOAST_THRESHOLD_MS, +} from '../constants' +import { projectSourceExternalUrl } from '../utils/projectSource' + +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': + openProjectSource(project) + break + case 'open': + void openProject(project) + break + case 'explorer': + openInExplorer(project) + break + case 'terminal': + void openInTerminal(project) + break + case 'copy': + 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/useProjectKeyboardNavigation.ts b/src/views/HomeView/composables/useProjectKeyboardNavigation.ts new file mode 100644 index 0000000..5f121ff --- /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' +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/useProjectRefs.ts b/src/views/HomeView/composables/useProjectRefs.ts new file mode 100644 index 0000000..07d77bf --- /dev/null +++ b/src/views/HomeView/composables/useProjectRefs.ts @@ -0,0 +1,56 @@ +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.transition = 'opacity 180ms ease-out, transform 180ms ease-out' + element.style.opacity = '0' + element.style.transform = 'translateX(-12px)' + + await new Promise(resolve => setTimeout(resolve, 180)) + } + + return { + projectItemRefs, + projectActionRefs, + setProjectItemRef, + setProjectActionsRef, + deleteProjectRefs, + clearProjectRefs, + playProjectRemoveAnimation, + } +} diff --git a/src/views/HomeView/utils/homeLayoutStorage.ts b/src/views/HomeView/utils/homeLayoutStorage.ts new file mode 100644 index 0000000..1e87b9b --- /dev/null +++ b/src/views/HomeView/utils/homeLayoutStorage.ts @@ -0,0 +1,21 @@ +import { HOME_LAYOUT_STORAGE_KEY } from '../constants' +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..928cb66 --- /dev/null +++ b/src/views/HomeView/utils/projectFormatters.ts @@ -0,0 +1,75 @@ +import type { CodeEditorEnum } from '~/constants/codeEditor' +import { codeEditors } from '~/constants/codeEditor' +import { LicenseEnum } from '~/constants/license' +import type { LocalProject } from '~/constants/localProject' +import { ProjectKind } from '~/constants/localProject' + +export function kindLabel(kind: ProjectKind, t: (key: string) => string) { + switch (kind) { + case ProjectKind.FORK: + return t('app.project_kind.fork') + case ProjectKind.CLONE: + return t('app.project_kind.clone') + case ProjectKind.MINE: + default: + return t('app.project_kind.mine') + } +} + +export function kindClass(kind: ProjectKind) { + switch (kind) { + case ProjectKind.FORK: + return 'kind-fork' + case ProjectKind.CLONE: + return 'kind-clone' + case ProjectKind.MINE: + default: + return 'kind-mine' + } +} + +export function shortLicense(license: LicenseEnum | undefined, t: (key: string) => string) { + if (!license || license === LicenseEnum.NONE) + return t('app.license.none') + if (license === LicenseEnum.MIT) + return 'MIT' + if (license === LicenseEnum.APACHE_2_0) + return 'Apache-2.0' + if (license === LicenseEnum.GPLV3) + return 'GPL-3.0' + if (license === LicenseEnum.GPLV2) + return 'GPL-2.0' + if (license === LicenseEnum.OTHER) + return t('app.license.other') + return license.replace(' License', '').replace('GNU ', '') +} + +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..372ab67 --- /dev/null +++ b/src/views/HomeView/utils/projectSource.ts @@ -0,0 +1,70 @@ +import type { LocalProject } from '~/constants/localProject' +import { ProjectKind } from '~/constants/localProject' + +import { GITHUB_SOURCE_RE } from '../constants' + +export function isSourceProject(project: LocalProject) { + return project.kind === ProjectKind.FORK || project.kind === ProjectKind.CLONE +} + +export function projectSourceUrl(project: LocalProject) { + return isSourceProject(project) ? project.fromUrl?.trim() || '' : '' +} + +export function projectSourceExternalUrl(project: LocalProject) { + const sourceUrl = projectSourceUrl(project) + if (!sourceUrl) + return '' + + const githubName = sourceUrl.match(GITHUB_SOURCE_RE)?.[1] + if (githubName) + return `https://github.com/${githubName.replace(/\.git$/, '')}` + + if (/^https?:\/\//i.test(sourceUrl)) + return sourceUrl + + return `https://${sourceUrl}` +} + +export function sourceNameFromUrl(url: string) { + const githubName = url.match(GITHUB_SOURCE_RE)?.[1] + if (githubName) + return githubName + + try { + const parsedUrl = new URL(url) + const pathParts = parsedUrl.pathname.split('/').filter(Boolean) + return pathParts.length >= 2 + ? pathParts.slice(-2).join('/') + : parsedUrl.hostname + } + catch { + return url + } +} + +export function projectSourceName(project: LocalProject) { + const sourceUrl = projectSourceUrl(project) + return isSourceProject(project) + ? project.fromName?.trim() || (sourceUrl ? sourceNameFromUrl(sourceUrl) : '') + : '' +} + +export function hasProjectSource(project: LocalProject) { + return !!projectSourceName(project) +} + +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 +} From d6100afdc965d4281dfa7fbc73482b246913eb1e Mon Sep 17 00:00:00 2001 From: MidnightCrowing <110297461+MidnightCrowing@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:51:35 +0800 Subject: [PATCH 2/7] fix(home): polish project metadata display --- src/components/LanguagePop/LanguagePop.vue | 5 +++- .../HomeView/components/ProjectListRow.vue | 7 +++++- .../components/project/ProjectMeta.vue | 22 ++++++++++++------ .../components/project/ProjectTitle.vue | 23 +++++++++++-------- .../HomeView/composables/useProjectFilters.ts | 4 +++- .../HomeView/composables/useProjectRefs.ts | 4 +++- src/views/HomeView/utils/projectFormatters.ts | 9 ++++++++ .../ProjectEditorView/ProjectEditorView.vue | 5 +++- 8 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/components/LanguagePop/LanguagePop.vue b/src/components/LanguagePop/LanguagePop.vue index cd887c4..40b5db9 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 } diff --git a/src/views/HomeView/components/ProjectListRow.vue b/src/views/HomeView/components/ProjectListRow.vue index 4e5a6d6..aea7136 100644 --- a/src/views/HomeView/components/ProjectListRow.vue +++ b/src/views/HomeView/components/ProjectListRow.vue @@ -75,8 +75,13 @@ const { t } = useI18n() - + + () + variant?: 'all' | 'language' | 'license' +}>(), { + variant: 'all', +}) const emit = defineEmits<{ showLanguage: [project: LocalProject, event: MouseEvent] }>() const { t } = useI18n() + +const showLanguage = computed(() => props.variant !== 'license') +const showLicense = computed(() => props.variant !== 'language') +const languageLabel = computed(() => formatProjectLanguage(props.project.mainLang, t))