feat(desktop): polish project sidebar

This commit is contained in:
DragonnZhang 2026-04-25 23:50:42 +08:00
parent d772bf8b56
commit c3a989e15a
9 changed files with 555 additions and 120 deletions

View file

@ -90,6 +90,7 @@ async function main() {
await clickButton('Chat'); await clickButton('Chat');
await clickButton('New Thread'); await clickButton('New Thread');
await waitForText('New thread ready'); await waitForText('New thread ready');
await waitForSelector('[data-testid="thread-list"]');
await setFieldByAriaLabel('Message', 'Please exercise command approval.'); await setFieldByAriaLabel('Message', 'Please exercise command approval.');
await clickButton('Send'); await clickButton('Send');

View file

@ -154,6 +154,7 @@ export interface DesktopSessionSummary {
sessionId: string; sessionId: string;
title?: string; title?: string;
cwd?: string; cwd?: string;
updatedAt?: string;
models?: DesktopSessionModelState; models?: DesktopSessionModelState;
modes?: DesktopSessionModeState; modes?: DesktopSessionModeState;
} }
@ -806,6 +807,8 @@ function isSessionSummary(value: unknown): value is DesktopSessionSummary {
typeof candidate.sessionId === 'string' && typeof candidate.sessionId === 'string' &&
(typeof candidate.title === 'string' || candidate.title === undefined) && (typeof candidate.title === 'string' || candidate.title === undefined) &&
(typeof candidate.cwd === 'string' || candidate.cwd === undefined) && (typeof candidate.cwd === 'string' || candidate.cwd === undefined) &&
(typeof candidate.updatedAt === 'string' ||
candidate.updatedAt === undefined) &&
(candidate.models === undefined || isModelState(candidate.models)) && (candidate.models === undefined || isModelState(candidate.models)) &&
(candidate.modes === undefined || isModeState(candidate.modes)) (candidate.modes === undefined || isModeState(candidate.modes))
); );

View file

@ -8,6 +8,12 @@ import type {
DesktopProject, DesktopProject,
DesktopSessionSummary, DesktopSessionSummary,
} from '../../api/client.js'; } from '../../api/client.js';
import {
FolderIcon,
FolderPlusIcon,
NewThreadIcon,
SlidersIcon,
} from './SidebarIcons.js';
import { ThreadList } from './ThreadList.js'; import { ThreadList } from './ThreadList.js';
import type { LoadState } from './types.js'; import type { LoadState } from './types.js';
@ -15,6 +21,7 @@ export function ProjectSidebar({
activeProject, activeProject,
activeProjectId, activeProjectId,
activeSessionId, activeSessionId,
isDraftSession,
loadState, loadState,
projects, projects,
sessions, sessions,
@ -27,6 +34,7 @@ export function ProjectSidebar({
activeProject: DesktopProject | null; activeProject: DesktopProject | null;
activeProjectId: string | null; activeProjectId: string | null;
activeSessionId: string | null; activeSessionId: string | null;
isDraftSession: boolean;
loadState: LoadState; loadState: LoadState;
projects: DesktopProject[]; projects: DesktopProject[];
sessions: DesktopSessionSummary[]; sessions: DesktopSessionSummary[];
@ -42,46 +50,44 @@ export function ProjectSidebar({
aria-label="Projects and threads" aria-label="Projects and threads"
data-testid="project-sidebar" data-testid="project-sidebar"
> >
<div className="brand-lockup"> <div className="sidebar-toolbar">
<div className="brand-mark" aria-hidden="true"> <h1>Projects</h1>
Q <div className="sidebar-toolbar-actions">
</div> <button
<div> aria-label="Settings"
<h1>Qwen Code</h1> className="sidebar-icon-button"
<p>Desktop</p> title="Settings"
type="button"
onClick={onOpenSettings}
>
<SlidersIcon />
<span className="sr-only">Settings</span>
</button>
<button
aria-label="New Thread"
className="sidebar-icon-button"
disabled={loadState.state !== 'ready' || !activeProject}
title="New Thread"
type="button"
onClick={onCreateSession}
>
<NewThreadIcon />
<span className="sr-only">New Thread</span>
</button>
<button
aria-label="Open Project"
className="sidebar-icon-button"
title="Open Project"
type="button"
onClick={onChooseWorkspace}
>
<FolderPlusIcon />
<span className="sr-only">Open Project</span>
</button>
</div> </div>
</div> </div>
<section className="sidebar-section quick-actions"> <section className="sidebar-section project-navigator">
<button
className="primary-button"
disabled={loadState.state !== 'ready' || !activeProject}
type="button"
onClick={onCreateSession}
>
New Thread
</button>
<button
className="secondary-button"
type="button"
onClick={onOpenSettings}
>
Settings
</button>
</section>
<section className="sidebar-section">
<h2>Projects</h2>
<div className="workspace-path">
{activeProject?.path || 'No folder selected'}
</div>
<button
className="secondary-button"
type="button"
onClick={onChooseWorkspace}
>
Open Project
</button>
<ProjectList <ProjectList
activeProjectId={activeProjectId} activeProjectId={activeProjectId}
projects={projects} projects={projects}
@ -90,9 +96,13 @@ export function ProjectSidebar({
</section> </section>
<section className="sidebar-section sidebar-section-fill"> <section className="sidebar-section sidebar-section-fill">
<h2>Threads</h2> <div className="sidebar-section-heading">
<h2>Threads</h2>
<span>{isDraftSession ? sessions.length + 1 : sessions.length}</span>
</div>
<ThreadList <ThreadList
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
isDraftSession={isDraftSession}
sessions={sessions} sessions={sessions}
onSelect={onSelectSession} onSelect={onSelectSession}
/> />
@ -111,7 +121,7 @@ function ProjectList({
onSelect: (projectId: string) => void; onSelect: (projectId: string) => void;
}) { }) {
if (projects.length === 0) { if (projects.length === 0) {
return <div className="empty-row">No recent projects</div>; return <div className="empty-row">No folder selected</div>;
} }
return ( return (
@ -131,10 +141,29 @@ function ProjectList({
onClick={() => onSelect(project.id)} onClick={() => onSelect(project.id)}
type="button" type="button"
> >
<span>{project.name}</span> <FolderIcon className="project-row-icon" />
<small>{project.gitBranch || 'No Git branch'}</small> <span className="project-row-copy">
<span>{project.name}</span>
<small>{formatProjectMeta(project)}</small>
</span>
</button> </button>
))} ))}
</div> </div>
); );
} }
function formatProjectMeta(project: DesktopProject): string {
const status = project.gitStatus;
const changes = status.modified + status.staged + status.untracked;
const branch = project.gitBranch || 'No Git branch';
if (!status.isRepository) {
return 'No Git repository';
}
if (changes > 0) {
return `${branch} · ${changes} changes`;
}
return branch;
}

View file

@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { SVGProps } from 'react';
type SidebarIconProps = SVGProps<SVGSVGElement>;
export function FolderIcon(props: SidebarIconProps) {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
{...props}
>
<path
d="M3.5 7.8c0-1.1.9-2 2-2h4.2l1.8 2.1h7c1.1 0 2 .9 2 2v7.5c0 1.1-.9 2-2 2h-13c-1.1 0-2-.9-2-2V7.8Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
/>
<path
d="M3.8 11h16.4"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
}
export function FolderPlusIcon(props: SidebarIconProps) {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
{...props}
>
<path
d="M3.5 7.8c0-1.1.9-2 2-2h4.2l1.8 2.1h7c1.1 0 2 .9 2 2v7.5c0 1.1-.9 2-2 2h-13c-1.1 0-2-.9-2-2V7.8Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
/>
<path
d="M15.5 14.9h4.2m-2.1-2.1V17"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
}
export function NewThreadIcon(props: SidebarIconProps) {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
{...props}
>
<path
d="M6.2 17.8 17.8 6.2M12.8 6.2h5v5M6.2 11.2v6.6h6.6"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
}
export function SlidersIcon(props: SidebarIconProps) {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
{...props}
>
<path
d="M5.5 7h13M8.5 12h7M10.5 17h3"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
);
}
export function OpenThreadIcon(props: SidebarIconProps) {
return (
<svg
aria-hidden="true"
fill="none"
height="18"
viewBox="0 0 24 24"
width="18"
{...props}
>
<path
d="M8.2 7.2h8.6v8.6M16.4 7.6 7.2 16.8"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</svg>
);
}

View file

@ -5,17 +5,20 @@
*/ */
import type { DesktopSessionSummary } from '../../api/client.js'; import type { DesktopSessionSummary } from '../../api/client.js';
import { OpenThreadIcon } from './SidebarIcons.js';
export function ThreadList({ export function ThreadList({
activeSessionId, activeSessionId,
isDraftSession,
sessions, sessions,
onSelect, onSelect,
}: { }: {
activeSessionId: string | null; activeSessionId: string | null;
isDraftSession: boolean;
sessions: DesktopSessionSummary[]; sessions: DesktopSessionSummary[];
onSelect: (sessionId: string) => void; onSelect: (sessionId: string) => void;
}) { }) {
if (sessions.length === 0) { if (!isDraftSession && sessions.length === 0) {
return <div className="empty-row">No sessions</div>; return <div className="empty-row">No sessions</div>;
} }
@ -25,21 +28,106 @@ export function ThreadList({
aria-label="Threads" aria-label="Threads"
data-testid="thread-list" data-testid="thread-list"
> >
{sessions.map((session) => ( {isDraftSession ? (
<button <div
className={ className="session-row session-row-active session-row-draft"
session.sessionId === activeSessionId role="status"
? 'session-row session-row-active'
: 'session-row'
}
key={session.sessionId}
onClick={() => onSelect(session.sessionId)}
type="button"
> >
<span>{session.title || session.sessionId}</span> <span className="session-row-title">New thread</span>
<small>{session.cwd || session.sessionId}</small> <span className="session-row-trailing">
</button> <span className="session-ring" aria-hidden="true" />
))} <span className="session-row-meta">draft</span>
</span>
</div>
) : null}
{sessions.map((session) => {
const meta = formatSessionMeta(session);
return (
<button
className={
session.sessionId === activeSessionId
? 'session-row session-row-active'
: 'session-row'
}
key={session.sessionId}
onClick={() => onSelect(session.sessionId)}
type="button"
>
<span className="session-row-title">
{session.title || session.sessionId}
</span>
<span className="session-row-trailing">
{session.sessionId === activeSessionId ? (
<span className="session-ring" aria-hidden="true" />
) : (
<OpenThreadIcon className="session-open-icon" />
)}
{meta ? <span className="session-row-meta">{meta}</span> : null}
</span>
</button>
);
})}
</div> </div>
); );
} }
function formatSessionMeta(session: DesktopSessionSummary): string | null {
const age = formatSessionAge(session.updatedAt);
if (age) {
return age;
}
return session.models?.currentModelId
? shortenModelId(session.models.currentModelId)
: null;
}
function formatSessionAge(updatedAt: string | undefined): string | null {
if (!updatedAt) {
return null;
}
const timestamp = Date.parse(updatedAt);
if (Number.isNaN(timestamp)) {
return null;
}
const elapsedMs = Math.max(0, Date.now() - timestamp);
const minuteMs = 60_000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
if (elapsedMs < minuteMs) {
return isChineseLocale() ? '刚刚' : 'now';
}
if (elapsedMs < hourMs) {
return formatAgeUnit(Math.floor(elapsedMs / minuteMs), 'm', '分');
}
if (elapsedMs < dayMs) {
return formatAgeUnit(Math.floor(elapsedMs / hourMs), 'h', '小时');
}
return formatAgeUnit(Math.floor(elapsedMs / dayMs), 'd', '天');
}
function formatAgeUnit(
value: number,
englishUnit: string,
chineseUnit: string,
) {
return isChineseLocale()
? `${value} ${chineseUnit}`
: `${value}${englishUnit}`;
}
function isChineseLocale(): boolean {
return /^zh(?:-|$)/iu.test(globalThis.navigator?.language ?? '');
}
function shortenModelId(modelId: string): string {
const normalized = modelId.split('/').pop() ?? modelId;
return normalized.length > 12 ? `${normalized.slice(0, 11)}...` : normalized;
}

View file

@ -154,6 +154,7 @@ export function WorkspacePage({
activeProject={activeProject} activeProject={activeProject}
activeProjectId={activeProjectId} activeProjectId={activeProjectId}
activeSessionId={activeSessionId} activeSessionId={activeSessionId}
isDraftSession={isDraftSession}
loadState={loadState} loadState={loadState}
projects={projects} projects={projects}
sessions={sessions} sessions={sessions}

View file

@ -15,6 +15,7 @@
--muted: #8c948c; --muted: #8c948c;
--accent: #55a6ff; --accent: #55a6ff;
--accent-soft: rgba(85, 166, 255, 0.16); --accent-soft: rgba(85, 166, 255, 0.16);
--accent-warm: #f2c770;
--success: #75d99c; --success: #75d99c;
--success-soft: rgba(117, 217, 156, 0.15); --success-soft: rgba(117, 217, 156, 0.15);
--warning: #f0b66e; --warning: #f0b66e;
@ -24,6 +25,8 @@
color: var(--text); color: var(--text);
background: var(--bg); background: var(--bg);
font-family: font-family:
'SF Pro Text',
'SF Pro Display',
ui-sans-serif, ui-sans-serif,
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
@ -79,7 +82,7 @@ summary:focus-visible {
.desktop-shell { .desktop-shell {
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr); grid-template-columns: 272px minmax(0, 1fr);
width: 100%; width: 100%;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
@ -93,51 +96,44 @@ summary:focus-visible {
height: 100vh; height: 100vh;
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 10px;
overflow: hidden; overflow: hidden;
padding: 18px 14px; padding: 16px 12px 14px;
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
background: background:
linear-gradient(180deg, rgba(40, 64, 65, 0.82), rgba(16, 19, 20, 0.96)), linear-gradient(180deg, rgba(37, 58, 61, 0.95), rgba(22, 28, 29, 0.98)),
#111415; radial-gradient(
circle at 32px 0,
rgba(242, 199, 112, 0.08),
transparent 34%
),
#151b1d;
} }
.brand-lockup { .sidebar-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: space-between;
min-height: 38px; min-height: 30px;
padding: 2px 4px 0; gap: 12px;
padding: 0 4px 4px;
} }
.brand-mark { .sidebar-toolbar h1,
display: grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
background: linear-gradient(135deg, #f2c770, #48a4ff);
color: #0f1112;
font-weight: 850;
}
.brand-lockup h1,
.brand-lockup p,
.topbar h2, .topbar h2,
.topbar p, .topbar p,
.panel h3 { .panel h3 {
margin: 0; margin: 0;
} }
.brand-lockup h1 { .sidebar-toolbar h1 {
font-size: 15px; color: rgba(225, 232, 228, 0.62);
font-weight: 780; font-size: 16px;
line-height: 1.1; font-weight: 720;
letter-spacing: 0;
line-height: 1;
} }
.brand-lockup p,
.eyebrow, .eyebrow,
.message-role { .message-role {
color: var(--muted); color: var(--muted);
@ -147,24 +143,64 @@ summary:focus-visible {
text-transform: uppercase; text-transform: uppercase;
} }
.quick-actions { .sidebar-toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-icon-button {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); width: 24px;
gap: 8px; height: 24px;
place-items: center;
border-radius: 6px;
background: transparent;
color: rgba(225, 232, 228, 0.58);
transition:
background 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.sidebar-icon-button:not(:disabled):hover {
background: rgba(238, 244, 239, 0.08);
color: rgba(245, 248, 244, 0.9);
transform: translateY(-1px);
}
.sidebar-icon-button svg {
display: block;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
} }
.sidebar-section { .sidebar-section {
display: flex; display: flex;
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
gap: 9px; gap: 4px;
} }
.sidebar-section-fill { .sidebar-section-fill {
flex: 1; flex: 1;
} }
.sidebar-section h2 { .project-navigator {
flex: 0 0 auto;
}
.sidebar-section h2,
.sidebar-section-heading span {
margin: 0; margin: 0;
color: var(--text-soft); color: var(--text-soft);
font-size: 11px; font-size: 11px;
@ -173,15 +209,25 @@ summary:focus-visible {
text-transform: uppercase; text-transform: uppercase;
} }
.sidebar-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 24px;
padding: 4px 8px 0 36px;
}
.sidebar-section-heading span {
color: rgba(225, 232, 228, 0.38);
}
.empty-row, .empty-row,
.workspace-path { .workspace-path {
min-height: 40px; min-height: 40px;
padding: 10px 12px; padding: 10px 2px;
border: 1px solid var(--line); color: rgba(225, 232, 228, 0.52);
border-radius: var(--radius); font-size: 15px;
background: rgba(10, 12, 13, 0.3); font-weight: 680;
color: var(--muted);
font-size: 12px;
} }
.workspace-path { .workspace-path {
@ -229,13 +275,13 @@ summary:focus-visible {
align-content: start; align-content: start;
grid-auto-rows: min-content; grid-auto-rows: min-content;
min-height: 0; min-height: 0;
gap: 7px; gap: 1px;
overflow: auto; overflow: auto;
padding-right: 2px; padding: 0 2px 0 0;
} }
.project-list { .project-list {
max-height: 190px; max-height: 132px;
} }
.session-list { .session-list {
@ -244,45 +290,137 @@ summary:focus-visible {
.project-row, .project-row,
.session-row { .session-row {
position: relative;
display: grid; display: grid;
align-items: center;
width: 100%; width: 100%;
min-height: 48px; min-height: 42px;
gap: 4px; border: 0;
padding: 10px 12px; border-radius: 6px;
border: 1px solid var(--line); background: transparent;
border-radius: var(--radius); color: rgba(233, 238, 235, 0.86);
background: rgba(238, 244, 239, 0.035);
color: var(--text-soft);
text-align: left; text-align: left;
transition:
background 140ms ease,
color 140ms ease;
}
.project-row {
grid-template-columns: 28px minmax(0, 1fr);
padding: 6px 8px 6px 6px;
}
.session-row {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 8px;
padding: 6px 8px 6px 36px;
} }
.project-row-active, .project-row-active,
.session-row-active { .session-row-active {
border-color: rgba(85, 166, 255, 0.48); background: rgba(238, 244, 239, 0.052);
background: rgba(85, 166, 255, 0.11);
color: var(--text); color: var(--text);
} }
.project-row span, .project-row-active::before,
.project-row small, .session-row-active::before {
.session-row span, position: absolute;
.session-row small { top: 9px;
bottom: 9px;
left: 0;
width: 2px;
border-radius: 999px;
background: rgba(242, 199, 112, 0.74);
content: '';
}
.project-row:not(.project-row-active):hover,
.session-row:not(.session-row-active):hover {
background: rgba(238, 244, 239, 0.035);
color: rgba(248, 250, 247, 0.95);
}
.project-row-icon {
justify-self: start;
width: 18px;
height: 18px;
margin-left: 0;
color: rgba(225, 232, 228, 0.82);
}
.project-row-copy,
.session-row-title {
min-width: 0;
overflow: hidden;
}
.project-row-copy {
display: grid;
gap: 2px;
}
.project-row-copy span,
.project-row-copy small,
.session-row-title,
.session-row-meta {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.project-row span, .project-row-copy span,
.session-row span { .session-row-title {
font-size: 13px; font-size: 14px;
font-weight: 680;
line-height: 1.25;
}
.project-row-copy small {
color: rgba(225, 232, 228, 0.48);
font-size: 10px;
font-weight: 680;
letter-spacing: 0;
}
.session-row-trailing {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 32px;
gap: 6px;
color: rgba(225, 232, 228, 0.52);
font-weight: 760; font-weight: 760;
} }
.project-row small, .session-open-icon {
.session-row small { width: 15px;
color: var(--muted); height: 15px;
color: rgba(225, 232, 228, 0.48);
}
.session-row:not(:hover):not(.session-row-active) .session-open-icon {
opacity: 0;
}
.session-ring {
width: 13px;
height: 13px;
border: 2px solid rgba(225, 232, 228, 0.2);
border-top-color: rgba(225, 232, 228, 0.5);
border-radius: 999px;
}
.session-row-meta {
max-width: 48px;
color: rgba(225, 232, 228, 0.54);
font-size: 11px; font-size: 11px;
font-weight: 720;
line-height: 1;
}
.session-row-draft {
cursor: default;
} }
.workbench { .workbench {

View file

@ -7,7 +7,7 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { basename, join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebSocket } from 'ws'; import { WebSocket } from 'ws';
import type { import type {
@ -195,6 +195,51 @@ describe('DesktopServer', () => {
}); });
}); });
it('derives recent project names from the project directory', async () => {
const projectPath = await createTempDirectory('qwen-desktop-project-');
const storePath = join(
await createTempDirectory('qwen-desktop-store-'),
'desktop-projects.json',
);
await writeFile(
storePath,
`${JSON.stringify(
{
version: 1,
projects: [
{
id: 'stored-project',
name: 'Custom Display Name',
path: projectPath,
lastOpenedAt: 1_774_704_300_000,
},
],
},
null,
2,
)}\n`,
'utf8',
);
const server = await createTestServer(undefined, undefined, storePath);
const listed = await getJson(server, '/api/projects', {
Authorization: 'Bearer test-token',
});
expect(listed.status).toBe(200);
expect(listed.body).toMatchObject({
ok: true,
projects: [
{
id: 'stored-project',
name: basename(projectPath),
path: projectPath,
},
],
});
expect(JSON.stringify(listed.body)).not.toContain('Custom Display Name');
});
it('returns project diffs and can stage and commit changes', async () => { it('returns project diffs and can stage and commit changes', async () => {
const projectPath = await createCommittedGitProject(); const projectPath = await createCommittedGitProject();
const storePath = join( const storePath = join(

View file

@ -78,7 +78,7 @@ export class DesktopProjectService {
const path = await normalizeDirectoryPath(projectPath); const path = await normalizeDirectoryPath(projectPath);
const storedProject: StoredProject = { const storedProject: StoredProject = {
id: createProjectId(path), id: createProjectId(path),
name: basename(path) || path, name: getProjectName(path),
path, path,
lastOpenedAt: this.now().getTime(), lastOpenedAt: this.now().getTime(),
}; };
@ -122,6 +122,7 @@ export class DesktopProjectService {
const gitStatus = await readGitStatus(project.path); const gitStatus = await readGitStatus(project.path);
return { return {
...project, ...project,
name: getProjectName(project.path),
gitBranch: gitStatus.branch, gitBranch: gitStatus.branch,
gitStatus, gitStatus,
}; };
@ -308,6 +309,10 @@ function createProjectId(path: string): string {
return createHash('sha256').update(path).digest('hex').slice(0, 16); return createHash('sha256').update(path).digest('hex').slice(0, 16);
} }
function getProjectName(path: string): string {
return basename(path) || path;
}
function isProjectStoreFile(value: unknown): value is ProjectStoreFile { function isProjectStoreFile(value: unknown): value is ProjectStoreFile {
if (!value || typeof value !== 'object') { if (!value || typeof value !== 'object') {
return false; return false;