Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions products/desktop/apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,14 @@ snapshots:
hash: v1.k4693efd2.e01a8a548c694761b9f2a575268f420f653ad7eb9d26c2e33dc245ab03415fcf.Z5Ut_gF62xXnxkDgBXxE3yk6UDIAEVX5Sw_rKBPsqps
settings-personalizationsettings--synced-truncated--light:
hash: v1.k4693efd2.3c9f0dfa759b8c174abd8754593f0db9fd1518ff5225cccdcf0c3e83d4cdbe77.BqdmyuIiYJuGmHCQiRVAaBMk-xgycYOgVfwuMqma4LY
sidebar-taskitem--in-repository-group--dark:
hash: v1.k4693efd2.601f072b55c719b74db2abf8e4aa7f268f7c2cf80a279590fe841198392b49ee.sL5RJdxs9Ur7fkB9J_SO2L9JXFEpW1l1fmmQpd_-hEA
sidebar-taskitem--in-repository-group--light:
hash: v1.k4693efd2.0ae532ca59c02e645355bb9f08af295fb0c2afcd2c8c3cc6acd178f276acb416.6sNqaOArOyd2Wns3UDl8uQyd4aiwAAncKTbYidsZ9yw
sidebar-taskitem--pinned--dark:
hash: v1.k4693efd2.fef3cdc0c8a5485cabbb7ac37830d119d8939e864e972ee7a9cbd5e8c1a99fa9.6r2emBachF9VHn2cz9a2ktly_0P6sGl1iKfoQt9Sxu4
sidebar-taskitem--pinned--light:
hash: v1.k4693efd2.8e36c4705d8650d806cf7c933684299aca5d751d1dbffd58e836e20015115bd5.gkDV3puY-rvStAc8WBaLWOOakDzZDYv8B3wTukt4QbQ
skill-buttons-skillbuttonactionmessage--add-analytics--dark:
hash: v1.k4693efd2.6d4ce4fac8e23a50efbbfaf24c2e11f8981f6778af2bfb2ca559749d7bda0eca.OnypKY5jyJSbQUZtTVcUy-eXC2euU-xQ2wFBYJOsKwI
skill-buttons-skillbuttonactionmessage--add-analytics--light:
Expand Down
28 changes: 16 additions & 12 deletions products/desktop/packages/core/src/sidebar/groupTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ export interface GroupableTask {
originProduct?: string;
}

export interface GroupableFolder {
path: string;
remoteUrl: string | null;
/** Set only on linked git worktrees: root of the main checkout. */
mainRepoPath?: string | null;
}

export const CUSTOM_IMAGES_GROUP_ID = "custom-images";
export const CUSTOM_IMAGES_GROUP_NAME = "Custom images";

export interface TaskGroup<T extends GroupableTask> {
id: string;
Expand Down Expand Up @@ -53,10 +61,9 @@ export function getRepositoryInfo(
return null;
}

export function folderGroupId(folder: {
path: string;
remoteUrl: string | null;
}): string {
export function folderGroupId(
folder: Pick<GroupableFolder, "path" | "remoteUrl">,
): string {
if (folder.remoteUrl) {
return normalizeRepoKey(folder.remoteUrl).toLowerCase();
}
Expand All @@ -70,13 +77,10 @@ export function folderGroupId(folder: {
* so prefer a folder that is not a linked worktree (`mainRepoPath` is set only
* on linked worktrees).
*/
export function findGroupFolder<
F extends {
path: string;
remoteUrl: string | null;
mainRepoPath?: string | null;
},
>(folders: F[], groupId: string): F | undefined {
export function findGroupFolder<F extends GroupableFolder>(
folders: F[],
groupId: string,
): F | undefined {
const matches = folders.filter((f) => folderGroupId(f) === groupId);
return matches.find((f) => !f.mainRepoPath) ?? matches[0];
}
Expand All @@ -95,7 +99,7 @@ export function groupByRepository<T extends GroupableTask>(
? CUSTOM_IMAGES_GROUP_ID
: (repository?.fullPath ?? "other");
const groupName = isImageBuilder
? "Custom images"
? CUSTOM_IMAGES_GROUP_NAME
: (repository?.name ?? "Other");

let group = groupMap.get(groupId);
Expand Down
116 changes: 116 additions & 0 deletions products/desktop/packages/core/src/sidebar/taskContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
formatTaskContext,
type TaskContextFolder,
type TaskContextTask,
} from "./taskContext";

const CODE_REPO = {
fullPath: "posthog/code",
name: "code",
organization: "PostHog",
};

function task(overrides: Partial<TaskContextTask> = {}): TaskContextTask {
return {
repository: CODE_REPO,
workspaceMode: "local",
branchName: null,
linkedBranch: null,
...overrides,
};
}

const mainClone: TaskContextFolder = {
name: "PostHog Desktop",
path: "/repos/code",
remoteUrl: "posthog/code",
mainRepoPath: null,
};

describe("formatTaskContext", () => {
it.each<{ name: string; task: TaskContextTask; expected: string | null }>([
{
name: "repository only when the task has no branch of its own",
task: task(),
expected: "code",
},
{
name: "repository and linked branch",
task: task({ linkedBranch: "posthog-code/fix-login" }),
expected: "code · posthog-code/fix-login",
},
{
name: "the worktree's checked-out branch when no branch is linked yet",
task: task({ workspaceMode: "worktree", branchName: "wt/parser" }),
expected: "code · wt/parser",
},
{
name: "the linked branch in preference to the checked-out branch",
task: task({
workspaceMode: "worktree",
branchName: "wt/parser",
linkedBranch: "posthog-code/parser",
}),
expected: "code · posthog-code/parser",
},
{
name: "no branch for a local task sitting on the default branch",
task: task({ workspaceMode: "local", branchName: "main" }),
expected: "code",
},
{
name: "repository only for a cloud task with no linked branch",
task: task({ workspaceMode: "cloud" }),
expected: "code",
},
{
name: "the custom-images group name for image-builder tasks",
task: task({ repository: null, originProduct: "image_builder" }),
expected: "Custom images",
},
{
name: "the branch alone when the task has no repository",
task: task({ repository: null, linkedBranch: "posthog-code/orphan" }),
expected: "posthog-code/orphan",
},
{
name: "null when there is neither a repository nor a branch",
task: task({ repository: null }),
expected: null,
},
])("renders $name", ({ task: subject, expected }) => {
expect(formatTaskContext(subject)).toBe(expected);
});

it("labels the repository with the registered folder's name", () => {
expect(formatTaskContext(task(), [mainClone])).toBe("PostHog Desktop");
});

it("labels a worktree task with its main checkout's folder name", () => {
const worktree: TaskContextFolder = {
name: "code-wt",
path: "/repos/code-wt",
remoteUrl: "posthog/code",
mainRepoPath: "/repos/code",
};

expect(
formatTaskContext(
task({ workspaceMode: "worktree", branchName: "wt/parser" }),
[worktree, mainClone],
),
).toBe("PostHog Desktop · wt/parser");
});

it("falls back to the repository name when no folder is registered", () => {
const unrelated: TaskContextFolder = {
name: "posthog",
path: "/repos/posthog",
remoteUrl: "posthog/posthog",
mainRepoPath: null,
};

expect(formatTaskContext(task(), [unrelated])).toBe("code");
});
});
52 changes: 52 additions & 0 deletions products/desktop/packages/core/src/sidebar/taskContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
CUSTOM_IMAGES_GROUP_NAME,
findGroupFolder,
type GroupableFolder,
} from "./groupTasks";
import type { TaskData } from "./sidebarData.types";

export type TaskContextTask = Pick<
TaskData,
| "repository"
| "originProduct"
| "workspaceMode"
| "branchName"
| "linkedBranch"
>;

export interface TaskContextFolder extends GroupableFolder {
name: string;
}

function repositoryLabel(
task: TaskContextTask,
folders: TaskContextFolder[],
): string | null {
if (task.originProduct === "image_builder") return CUSTOM_IMAGES_GROUP_NAME;
const repository = task.repository;
if (!repository) return null;
// The registered folder's name is what the group header shows. No collision
// prefix here: pinned tasks are partitioned out before groups are built.
return findGroupFolder(folders, repository.fullPath)?.name ?? repository.name;
}

function branchLabel(task: TaskContextTask): string | null {
// `linkedBranch` stays unset while a task sits on the repo's default branch,
// so rows never all repeat "· main". Worktrees fall back to their checkout.
if (task.linkedBranch) return task.linkedBranch;
return task.workspaceMode === "worktree" ? task.branchName : null;
}

/**
* `<repository> · <branch>` line for a row rendered outside its repository
* group (the pinned section), where no group header supplies the context.
*/
export function formatTaskContext(
task: TaskContextTask,
folders: TaskContextFolder[] = [],
): string | null {
const repository = repositoryLabel(task, folders);
const branch = branchLabel(task);
if (repository && branch) return `${repository} · ${branch}`;
return repository ?? branch;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { Button, cn } from "@posthog/quill";
import {
Button,
cn,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@posthog/quill";
import type { SidebarItemAction } from "@posthog/ui/features/sidebar/types";
import {
OverflowTickerText,
useOverflowTickerReveal,
} from "@posthog/ui/primitives/OverflowTickerText";
import type { ComponentPropsWithRef } from "react";
import { type ComponentPropsWithRef, useCallback } from "react";

export const INDENT_SIZE = 8;

Expand Down Expand Up @@ -36,6 +43,51 @@ interface SidebarItemProps
disabled?: boolean;
}

function SidebarItemLabel({
label,
grow,
className,
}: {
label: React.ReactNode;
grow: boolean;
className?: string;
}) {
const canTooltip = typeof label === "string" || typeof label === "number";

const measureRef = useCallback((el: HTMLSpanElement | null) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟦 Nice-to-have

I wonder if this should belong in a reusable hook

if (!el) return;
const update = () => {
el.style.pointerEvents = el.scrollWidth > el.clientWidth ? "" : "none";
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);

const span = (
<span
ref={measureRef}
className={cn("min-w-0 truncate", grow && "flex-1", className)}
>
{label}
</span>
);

if (!canTooltip) return span;

return (
<TooltipProvider delay={600}>
<Tooltip>
<TooltipTrigger render={span} />
<TooltipContent side="top" className="max-w-[900px] break-words">
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

export function SidebarItem({
depth,
icon,
Expand Down Expand Up @@ -65,6 +117,8 @@ export function SidebarItem({
className={cn(
"group flex w-full cursor-default text-left text-[13px] leading-snug transition-colors",
"disabled:opacity-100 data-active:bg-fill-selected data-selected:bg-(--gray-3)",
// Quill's Button pins a fixed single-line height that would clip the second line.
subtitle && "h-auto! min-h-7 py-1",
isDimmed && "opacity-50",
)}
data-active={isActive || undefined}
Expand Down Expand Up @@ -103,9 +157,11 @@ export function SidebarItem({
{endContent}
</span>
{subtitle ? (
<span className="truncate text-gray-10 group-data-active:text-gray-11">
{subtitle}
</span>
<SidebarItemLabel
label={subtitle}
grow={false}
className="text-[11px] text-gray-10 leading-tight group-data-active:text-gray-11"
/>
) : null}
</span>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
TaskData,
TaskGroup,
} from "@posthog/core/sidebar/sidebarData.types";
import { formatTaskContext } from "@posthog/core/sidebar/taskContext";
import { MenuLabel } from "@posthog/quill";
import { builderHog } from "@posthog/ui/assets/hedgehogs";
import { useFolders } from "@posthog/ui/features/folders/useFolders";
Expand Down Expand Up @@ -61,6 +62,7 @@ function SectionLabel({ label }: { label: string }) {

function TaskRow({
task,
subtitle,
isActive,
isSelected,
hideHoverActions,
Expand All @@ -76,6 +78,7 @@ function TaskRow({
depth = 0,
}: {
task: TaskData;
subtitle?: string;
isActive: boolean;
isSelected: boolean;
hideHoverActions: boolean;
Expand Down Expand Up @@ -105,6 +108,7 @@ function TaskRow({
depth={depth}
taskId={task.id}
label={task.title}
subtitle={subtitle}
isActive={isActive}
isSelected={isSelected}
isArchiving={isArchiving}
Expand Down Expand Up @@ -208,6 +212,9 @@ export function TaskListView({
<TaskRow
key={task.id}
task={task}
// Pinned rows sit outside the per-project groups, so no group
// header supplies their repository.
subtitle={formatTaskContext(task, folders) ?? undefined}
isActive={activeTaskId === task.id}
isSelected={selectedIdSet.has(task.id)}
hideHoverActions={hasMultiSelection}
Expand Down
Loading
Loading