feat: simplify task creation

This commit is contained in:
Carlos Florêncio
2026-01-13 22:41:52 +00:00
parent aebcf5283f
commit aed307d9b8
19 changed files with 2566 additions and 216 deletions
@@ -144,9 +144,18 @@ func (c *RepositoryController) ListRepositoryBranches(ctx context.Context, req d
if err != nil {
return dto.RepositoryBranchesResponse{}, err
}
// Convert service.Branch to dto.BranchDTO
dtoBranches := make([]dto.BranchDTO, len(branches))
for i, branch := range branches {
dtoBranches[i] = dto.BranchDTO{
Name: branch.Name,
Type: branch.Type,
Remote: branch.Remote,
}
}
return dto.RepositoryBranchesResponse{
Branches: branches,
Total: len(branches),
Branches: dtoBranches,
Total: len(dtoBranches),
}, nil
}
+8 -2
View File
@@ -151,9 +151,15 @@ type ListEnvironmentsResponse struct {
Total int `json:"total"`
}
type BranchDTO struct {
Name string `json:"name"`
Type string `json:"type"` // "local" or "remote"
Remote string `json:"remote"` // remote name (e.g., "origin") for remote branches
}
type RepositoryBranchesResponse struct {
Branches []string `json:"branches"`
Total int `json:"total"`
Branches []BranchDTO `json:"branches"`
Total int `json:"total"`
}
type LocalRepositoryDTO struct {
@@ -22,6 +22,12 @@ type LocalRepository struct {
DefaultBranch string
}
type Branch struct {
Name string
Type string // "local" or "remote"
Remote string // remote name for remote branches
}
type RepositoryDiscoveryResult struct {
Roots []string
Repositories []LocalRepository
@@ -117,7 +123,7 @@ func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string)
}, nil
}
func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([]string, error) {
func (s *Service) ListRepositoryBranches(ctx context.Context, repoID string) ([]Branch, error) {
repo, err := s.repo.GetRepository(ctx, repoID)
if err != nil {
return nil, err
@@ -338,32 +344,74 @@ func resolveGitDir(repoPath string) (string, error) {
return filepath.Clean(filepath.Join(repoPath, gitDir)), nil
}
func listGitBranches(repoPath string) ([]string, error) {
func listGitBranches(repoPath string) ([]Branch, error) {
gitDir, err := resolveGitDir(repoPath)
if err != nil {
return nil, err
}
branches := make(map[string]struct{})
branchMap := make(map[string]Branch)
refsRoot := filepath.Join(gitDir, "refs", "heads")
_ = filepath.WalkDir(refsRoot, func(path string, d fs.DirEntry, err error) error {
// List local branches from refs/heads
localRefsRoot := filepath.Join(gitDir, "refs", "heads")
_ = filepath.WalkDir(localRefsRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(refsRoot, path)
rel, err := filepath.Rel(localRefsRoot, path)
if err != nil {
return nil
}
if rel == "" || rel == "." {
return nil
}
branches[filepath.ToSlash(rel)] = struct{}{}
name := filepath.ToSlash(rel)
branchMap[name] = Branch{
Name: name,
Type: "local",
}
return nil
})
// List remote branches from refs/remotes
remoteRefsRoot := filepath.Join(gitDir, "refs", "remotes")
_ = filepath.WalkDir(remoteRefsRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(remoteRefsRoot, path)
if err != nil {
return nil
}
if rel == "" || rel == "." {
return nil
}
fullPath := filepath.ToSlash(rel)
parts := strings.SplitN(fullPath, "/", 2)
if len(parts) < 2 {
return nil
}
remoteName := parts[0]
branchName := parts[1]
// Skip HEAD references
if branchName == "HEAD" {
return nil
}
key := "remotes/" + fullPath
branchMap[key] = Branch{
Name: branchName,
Type: "remote",
Remote: remoteName,
}
return nil
})
// Read packed-refs file for additional branches
packedRefs := filepath.Join(gitDir, "packed-refs")
if content, err := os.ReadFile(packedRefs); err == nil {
lines := strings.Split(string(content), "\n")
@@ -378,18 +426,55 @@ func listGitBranches(repoPath string) ([]string, error) {
}
ref := parts[1]
if strings.HasPrefix(ref, "refs/heads/") {
branches[strings.TrimPrefix(ref, "refs/heads/")] = struct{}{}
name := strings.TrimPrefix(ref, "refs/heads/")
if _, exists := branchMap[name]; !exists {
branchMap[name] = Branch{
Name: name,
Type: "local",
}
}
} else if strings.HasPrefix(ref, "refs/remotes/") {
fullPath := strings.TrimPrefix(ref, "refs/remotes/")
remoteParts := strings.SplitN(fullPath, "/", 2)
if len(remoteParts) < 2 {
continue
}
remoteName := remoteParts[0]
branchName := remoteParts[1]
if branchName == "HEAD" {
continue
}
key := "remotes/" + fullPath
if _, exists := branchMap[key]; !exists {
branchMap[key] = Branch{
Name: branchName,
Type: "remote",
Remote: remoteName,
}
}
}
}
}
if len(branches) == 0 {
if len(branchMap) == 0 {
return nil, fmt.Errorf("no branches found")
}
result := make([]string, 0, len(branches))
for name := range branches {
result = append(result, name)
result := make([]Branch, 0, len(branchMap))
for _, branch := range branchMap {
result = append(result, branch)
}
sort.Strings(result)
// Sort: local branches first, then remote branches, alphabetically within each group
sort.Slice(result, func(i, j int) bool {
if result[i].Type != result[j].Type {
return result[i].Type == "local"
}
if result[i].Type == "remote" && result[i].Remote != result[j].Remote {
return result[i].Remote < result[j].Remote
}
return result[i].Name < result[j].Name
})
return result, nil
}
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/kandev/kandev/internal/common/logger"
@@ -49,6 +50,204 @@ func TestDiscoverLocalRepositoriesSkipsIgnoredRoots(t *testing.T) {
}
}
func TestValidateLocalRepositoryPath(t *testing.T) {
root := t.TempDir()
svc := newDiscoveryService(t, root)
otherRoot := t.TempDir()
makeRepo(t, otherRoot)
outside, err := svc.ValidateLocalRepositoryPath(context.Background(), otherRoot)
if err != nil {
t.Fatalf("ValidateLocalRepositoryPath outside error: %v", err)
}
if outside.Allowed {
t.Fatalf("expected outside path to be disallowed")
}
if outside.Message != "Path is outside the allowed roots" {
t.Fatalf("expected outside message, got %q", outside.Message)
}
missingPath := filepath.Join(root, "missing")
missing, err := svc.ValidateLocalRepositoryPath(context.Background(), missingPath)
if err != nil {
t.Fatalf("ValidateLocalRepositoryPath missing error: %v", err)
}
if !missing.Allowed || missing.Exists {
t.Fatalf("expected missing path to be allowed and not exist")
}
if missing.Message != "Path does not exist" {
t.Fatalf("expected missing message, got %q", missing.Message)
}
filePath := filepath.Join(root, "file.txt")
if err := os.WriteFile(filePath, []byte("data"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
fileResult, err := svc.ValidateLocalRepositoryPath(context.Background(), filePath)
if err != nil {
t.Fatalf("ValidateLocalRepositoryPath file error: %v", err)
}
if !fileResult.Allowed || !fileResult.Exists {
t.Fatalf("expected file path to be allowed and exist")
}
if fileResult.Message != "Path is not a directory" {
t.Fatalf("expected file message, got %q", fileResult.Message)
}
plainDir := filepath.Join(root, "plain")
if err := os.MkdirAll(plainDir, 0o755); err != nil {
t.Fatalf("mkdir plain dir: %v", err)
}
plainResult, err := svc.ValidateLocalRepositoryPath(context.Background(), plainDir)
if err != nil {
t.Fatalf("ValidateLocalRepositoryPath plain error: %v", err)
}
if plainResult.Message != "Not a git repository" {
t.Fatalf("expected plain message, got %q", plainResult.Message)
}
repoPath := filepath.Join(root, "repo")
makeRepo(t, repoPath)
repoResult, err := svc.ValidateLocalRepositoryPath(context.Background(), repoPath)
if err != nil {
t.Fatalf("ValidateLocalRepositoryPath repo error: %v", err)
}
if !repoResult.IsGitRepo || repoResult.DefaultBranch != "main" || repoResult.Message != "" {
t.Fatalf("expected git repo with main branch, got %+v", repoResult)
}
}
func TestNormalizeRootsDedupesAndCleans(t *testing.T) {
root := t.TempDir()
roots := []string{root, root + string(os.PathSeparator), "", root}
normalized := normalizeRoots(roots)
if len(normalized) != 1 {
t.Fatalf("expected 1 normalized root, got %d: %#v", len(normalized), normalized)
}
if normalized[0] != filepath.Clean(root) {
t.Fatalf("expected normalized root %q, got %q", filepath.Clean(root), normalized[0])
}
}
func TestReadGitDefaultBranch(t *testing.T) {
repoPath := t.TempDir()
gitDir := filepath.Join(repoPath, ".git")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatalf("mkdir git: %v", err)
}
headPath := filepath.Join(gitDir, "HEAD")
if err := os.WriteFile(headPath, []byte("ref: refs/heads/develop\n"), 0o644); err != nil {
t.Fatalf("write HEAD: %v", err)
}
branch, err := readGitDefaultBranch(repoPath)
if err != nil {
t.Fatalf("readGitDefaultBranch error: %v", err)
}
if branch != "develop" {
t.Fatalf("expected develop, got %q", branch)
}
if err := os.WriteFile(headPath, []byte("3a3f2d3b\n"), 0o644); err != nil {
t.Fatalf("write HEAD detached: %v", err)
}
branch, err = readGitDefaultBranch(repoPath)
if err != nil {
t.Fatalf("readGitDefaultBranch detached error: %v", err)
}
if branch != "HEAD" {
t.Fatalf("expected HEAD, got %q", branch)
}
if err := os.WriteFile(headPath, []byte("\n"), 0o644); err != nil {
t.Fatalf("write HEAD empty: %v", err)
}
if _, err := readGitDefaultBranch(repoPath); err == nil {
t.Fatalf("expected error for empty HEAD")
}
}
func TestResolveGitDir(t *testing.T) {
repoPath := t.TempDir()
gitDir := filepath.Join(repoPath, ".git")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatalf("mkdir git: %v", err)
}
resolved, err := resolveGitDir(repoPath)
if err != nil {
t.Fatalf("resolveGitDir dir error: %v", err)
}
if resolved != gitDir {
t.Fatalf("expected git dir %q, got %q", gitDir, resolved)
}
altRepo := t.TempDir()
altGit := filepath.Join(altRepo, "gitdir")
if err := os.MkdirAll(altGit, 0o755); err != nil {
t.Fatalf("mkdir alt git: %v", err)
}
gitRef := filepath.Join(altRepo, ".git")
relPath := filepath.Join("gitdir")
if err := os.WriteFile(gitRef, []byte("gitdir: "+relPath+"\n"), 0o644); err != nil {
t.Fatalf("write gitdir ref: %v", err)
}
resolved, err = resolveGitDir(altRepo)
if err != nil {
t.Fatalf("resolveGitDir file error: %v", err)
}
expected := filepath.Clean(filepath.Join(altRepo, relPath))
if resolved != expected {
t.Fatalf("expected git dir %q, got %q", expected, resolved)
}
}
func TestListGitBranches(t *testing.T) {
repoPath := t.TempDir()
gitDir := filepath.Join(repoPath, ".git")
if err := os.MkdirAll(gitDir, 0o755); err != nil {
t.Fatalf("mkdir git: %v", err)
}
writeRef(t, filepath.Join(gitDir, "refs", "heads", "main"))
writeRef(t, filepath.Join(gitDir, "refs", "heads", "feature", "test"))
writeRef(t, filepath.Join(gitDir, "refs", "remotes", "origin", "main"))
writeRef(t, filepath.Join(gitDir, "refs", "remotes", "origin", "HEAD"))
writeRef(t, filepath.Join(gitDir, "refs", "remotes", "upstream", "dev"))
packedRefs := strings.Join([]string{
"# pack-refs with: peeled fully-peeled",
"deadbeef refs/heads/packed",
"cafebabe refs/remotes/origin/packed-remote",
"^deadbeef",
"",
}, "\n")
if err := os.WriteFile(filepath.Join(gitDir, "packed-refs"), []byte(packedRefs), 0o644); err != nil {
t.Fatalf("write packed-refs: %v", err)
}
branches, err := listGitBranches(repoPath)
if err != nil {
t.Fatalf("listGitBranches error: %v", err)
}
expected := []Branch{
{Name: "feature/test", Type: "local"},
{Name: "main", Type: "local"},
{Name: "packed", Type: "local"},
{Name: "main", Type: "remote", Remote: "origin"},
{Name: "packed-remote", Type: "remote", Remote: "origin"},
{Name: "dev", Type: "remote", Remote: "upstream"},
}
if len(branches) != len(expected) {
t.Fatalf("expected %d branches, got %d: %#v", len(expected), len(branches), branches)
}
for i, branch := range branches {
if branch != expected[i] {
t.Fatalf("expected branch %#v, got %#v", expected[i], branch)
}
}
}
func makeRepo(t *testing.T, path string) {
t.Helper()
gitDir := filepath.Join(path, ".git")
@@ -61,6 +260,16 @@ func makeRepo(t *testing.T, path string) {
}
}
func writeRef(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir ref dir: %v", err)
}
if err := os.WriteFile(path, []byte("0000000\n"), 0o644); err != nil {
t.Fatalf("write ref: %v", err)
}
}
func newDiscoveryService(t *testing.T, root string) *Service {
t.Helper()
repo := repository.NewMemoryRepository()
+1450
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -13,6 +13,7 @@
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
@@ -23,6 +24,7 @@
"@tabler/icons-react": "^3.24.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"react-resizable-panels": "^2.1.9",
"tailwind-merge": "^2.6.0"
},
+155
View File
@@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { IconSearch } from "@tabler/icons-react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "./utils"
import { Dialog, DialogContent } from "./dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<IconSearch className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+2
View File
@@ -4,10 +4,12 @@ export * from './breadcrumb';
export * from './button';
export * from './card';
export * from './collapsible';
export * from './command';
export * from './dialog';
export * from './dropdown-menu';
export * from './input';
export * from './label';
export * from './popover';
export * from './resizable';
export * from './select';
export * from './separator';
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "./utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import { IconCheck, IconSelector } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { Button } from "@kandev/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@kandev/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@kandev/ui/popover"
export type ComboboxOption = {
value: string
label: string
description?: string
renderLabel?: () => React.ReactNode
}
interface ComboboxProps {
options: ComboboxOption[]
value: string
onValueChange: (value: string) => void
dropdownLabel?: string
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
disabled?: boolean
className?: string
triggerClassName?: string
}
export function Combobox({
options,
value,
onValueChange,
dropdownLabel,
placeholder = "Select option...",
searchPlaceholder = "Search...",
emptyMessage = "No option found.",
disabled = false,
className,
triggerClassName,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", triggerClassName)}
disabled={disabled}
>
<div className="flex min-w-0 flex-1 items-center">
{selectedOption?.renderLabel ? (
selectedOption.renderLabel()
) : (
<span className="truncate">{selectedOption?.label || placeholder}</span>
)}
</div>
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("w-[var(--radix-popover-trigger-width)] max-w-none p-0", className)} align="start">
<Command>
{dropdownLabel ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">
{dropdownLabel}
</div>
) : null}
<CommandInput placeholder={searchPlaceholder} className="h-9" />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? "" : currentValue)
setOpen(false)
}}
>
{option.renderLabel ? option.renderLabel() : option.label}
<IconCheck
className={cn(
"ml-auto h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
+11 -1
View File
@@ -10,7 +10,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@kandev/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { cn, extractRepoName } from '@/lib/utils';
export interface Task {
id: string;
@@ -30,9 +30,14 @@ interface KanbanCardProps {
}
function KanbanCardLayout({ task, className }: KanbanCardProps & { className?: string }) {
const repoName = extractRepoName(task.repositoryUrl);
return (
<Card size="sm" className={cn('w-full py-0', className)}>
<CardContent className="px-2 py-1">
{repoName && (
<p className="text-xs text-muted-foreground leading-tight mb-1 truncate">{repoName}</p>
)}
<p className="text-sm font-medium leading-tight">{task.title}</p>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 leading-tight">{task.description}</p>
@@ -47,6 +52,8 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps)
id: task.id,
});
const repoName = extractRepoName(task.repositoryUrl);
const statusIcon = (() => {
switch (task.state) {
case 'IN_PROGRESS':
@@ -89,6 +96,9 @@ export function KanbanCard({ task, onClick, onEdit, onDelete }: KanbanCardProps)
{...attributes}
>
<CardContent className="px-2 py-1">
{repoName && (
<p className="text-xs text-muted-foreground leading-tight mb-1 truncate">{repoName}</p>
)}
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-tight">{task.title}</p>
<div className="flex items-center gap-2">
+282 -192
View File
@@ -1,6 +1,8 @@
'use client';
import { useEffect, useRef, useState, FormEvent } from 'react';
import Link from 'next/link';
import { IconSettings } from '@tabler/icons-react';
import {
Dialog,
DialogContent,
@@ -16,13 +18,24 @@ import { Button } from '@kandev/ui/button';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import { Combobox } from './combobox';
import { Badge } from '@kandev/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
import type { Repository, Task } from '@/lib/types/http';
import type { Repository, Task, Branch } from '@/lib/types/http';
import { getBackendConfig } from '@/lib/config';
import {
DEFAULT_LOCAL_ENVIRONMENT_KIND,
DEFAULT_LOCAL_EXECUTOR_TYPE,
formatUserHomePath,
selectPreferredBranch,
truncateRepoPath,
} from '@/lib/utils';
import { createTask, listRepositories, listRepositoryBranches, updateTask } from '@/lib/http/client';
type AgentProfileOption = {
@@ -33,11 +46,13 @@ type AgentProfileOption = {
type EnvironmentOption = {
id: string;
name: string;
kind: string;
};
type ExecutorOption = {
id: string;
name: string;
type: string;
};
interface TaskCreateDialogProps {
@@ -76,11 +91,10 @@ export function TaskCreateDialog({
const [description, setDescription] = useState(initialValues?.description ?? '');
const [repositoryId, setRepositoryId] = useState(initialValues?.repositoryId ?? '');
const [branch, setBranch] = useState(initialValues?.branch ?? '');
const [taskState, setTaskState] = useState<Task['state']>(initialValues?.state ?? 'CREATED');
const [startAgent, setStartAgent] = useState(false);
const [repositories, setRepositories] = useState<Repository[]>([]);
const [repositoriesLoading, setRepositoriesLoading] = useState(false);
const [branchesByRepo, setBranchesByRepo] = useState<Record<string, string[]>>({});
const [branchesByRepo, setBranchesByRepo] = useState<Record<string, Branch[]>>({});
const [branchesLoading, setBranchesLoading] = useState(false);
const [agentProfileId, setAgentProfileId] = useState('');
const [agentProfiles, setAgentProfiles] = useState<AgentProfileOption[]>([]);
@@ -91,6 +105,7 @@ export function TaskCreateDialog({
const [executorId, setExecutorId] = useState('');
const [executors, setExecutors] = useState<ExecutorOption[]>([]);
const [executorsLoading, setExecutorsLoading] = useState(false);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const descriptionRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
@@ -99,17 +114,14 @@ export function TaskCreateDialog({
setDescription(initialValues?.description ?? '');
setRepositoryId(initialValues?.repositoryId ?? '');
setBranch(initialValues?.branch ?? '');
setTaskState(initialValues?.state ?? editingTask?.state ?? 'CREATED');
setStartAgent(false);
setAgentProfileId('');
setEnvironmentId('');
setExecutorId('');
}, [
editingTask?.state,
initialValues?.branch,
initialValues?.description,
initialValues?.repositoryId,
initialValues?.state,
initialValues?.title,
open,
]);
@@ -120,8 +132,9 @@ export function TaskCreateDialog({
listRepositories(getBackendConfig().apiBaseUrl, workspaceId)
.then((response) => {
setRepositories(response.repositories);
if (editingTask?.description && !description) {
setDescription(editingTask.description);
// Auto-select if only one repository
if (response.repositories.length === 1) {
setRepositoryId(response.repositories[0].id);
}
})
.catch(() => {
@@ -130,15 +143,32 @@ export function TaskCreateDialog({
.finally(() => {
setRepositoriesLoading(false);
});
}, [description, editingTask?.description, open, workspaceId]);
}, [open, workspaceId]);
useEffect(() => {
if (!repositoryId) return;
if (branchesByRepo[repositoryId]) return;
if (branchesByRepo[repositoryId]) {
if (!branch) {
const preferredBranch = selectPreferredBranch(branchesByRepo[repositoryId]);
if (preferredBranch) {
setBranch(preferredBranch);
return;
}
}
return;
}
setBranchesLoading(true);
listRepositoryBranches(getBackendConfig().apiBaseUrl, repositoryId)
.then((response) => {
setBranchesByRepo((prev) => ({ ...prev, [repositoryId]: response.branches }));
if (!branch) {
const preferredBranch = selectPreferredBranch(response.branches);
if (preferredBranch) {
setBranch(preferredBranch);
return;
}
}
})
.catch(() => {
setBranchesByRepo((prev) => ({ ...prev, [repositoryId]: [] }));
@@ -146,7 +176,7 @@ export function TaskCreateDialog({
.finally(() => {
setBranchesLoading(false);
});
}, [branchesByRepo, repositoryId]);
}, [branchesByRepo, repositoryId, branch]);
useEffect(() => {
if (!open) return;
@@ -175,6 +205,12 @@ export function TaskCreateDialog({
});
}, [open]);
useEffect(() => {
if (!open || agentProfileId || agentProfiles.length === 0) return;
if (isEditMode && agentProfiles.length > 1) return;
setAgentProfileId(agentProfiles[0].id);
}, [open, isEditMode, agentProfileId, agentProfiles]);
useEffect(() => {
if (!open) return;
const apiBaseUrl = getBackendConfig().apiBaseUrl;
@@ -185,9 +221,10 @@ export function TaskCreateDialog({
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!data?.environments) return [];
return data.environments.map((env: { id: string; name: string }) => ({
return data.environments.map((env: { id: string; name: string; kind: string }) => ({
id: env.id,
name: env.name,
kind: env.kind,
}));
})
.catch(() => []),
@@ -195,9 +232,10 @@ export function TaskCreateDialog({
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (!data?.executors) return [];
return data.executors.map((executor: { id: string; name: string }) => ({
return data.executors.map((executor: { id: string; name: string; type: string }) => ({
id: executor.id,
name: executor.name,
type: executor.type,
}));
})
.catch(() => []),
@@ -212,6 +250,22 @@ export function TaskCreateDialog({
});
}, [open]);
useEffect(() => {
if (!open || isEditMode || environmentId || environments.length === 0) return;
const localEnvironment = environments.find(
(environment) => environment.kind === DEFAULT_LOCAL_ENVIRONMENT_KIND
);
setEnvironmentId(localEnvironment?.id ?? environments[0].id);
}, [open, isEditMode, environmentId, environments]);
useEffect(() => {
if (!open || isEditMode || executorId || executors.length === 0) return;
const localExecutor = executors.find(
(executor) => executor.type === DEFAULT_LOCAL_EXECUTOR_TYPE
);
setExecutorId(localExecutor?.id ?? executors[0].id);
}, [open, isEditMode, executorId, executors]);
useEffect(() => {
const textarea = descriptionRef.current;
if (!textarea) return;
@@ -228,7 +282,6 @@ export function TaskCreateDialog({
const updatedTask = await updateTask(getBackendConfig().apiBaseUrl, editingTask.id, {
title: trimmedTitle,
description: description.trim(),
state: taskState,
});
onSuccess?.(updatedTask, 'edit');
} finally {
@@ -236,7 +289,6 @@ export function TaskCreateDialog({
setDescription('');
setRepositoryId('');
setBranch('');
setTaskState('CREATED');
setStartAgent(false);
setAgentProfileId('');
onOpenChange(false);
@@ -248,7 +300,7 @@ export function TaskCreateDialog({
const columnId = editingTask?.columnId ?? defaultColumnId;
if (!columnId) return;
let targetColumnId = columnId;
let targetState: Task['state'] | undefined = taskState ?? 'CREATED';
let targetState: Task['state'] = 'CREATED';
if (startAgent && !isEditMode) {
const progressColumn = columns.find((column) =>
column.title.toLowerCase().includes('progress')
@@ -277,7 +329,6 @@ export function TaskCreateDialog({
setDescription('');
setRepositoryId('');
setBranch('');
setTaskState('CREATED');
setStartAgent(false);
setAgentProfileId('');
setEnvironmentId('');
@@ -291,7 +342,6 @@ export function TaskCreateDialog({
setDescription('');
setRepositoryId('');
setBranch('');
setTaskState('CREATED');
setStartAgent(false);
setAgentProfileId('');
setEnvironmentId('');
@@ -301,194 +351,234 @@ export function TaskCreateDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-none w-[900px] sm:!max-w-none">
<DialogContent className="max-w-none w-[900px] sm:!max-w-none max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>{submitLabel === 'Create' ? 'Create Task' : 'Edit Task'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Title</Label>
<Input
autoFocus
required
placeholder="Enter task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Prompt</Label>
<Textarea
ref={descriptionRef}
placeholder="Write a prompt for the agent..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="min-h-[96px] resize-none"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Repository</Label>
<Select
value={repositoryId}
onValueChange={(value) => {
setRepositoryId(value);
setBranch('');
}}
disabled={isEditMode}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={repositoriesLoading ? 'Loading repositories...' : 'Select repository'} />
</SelectTrigger>
<SelectContent>
{repositories.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
{repo.name}
</SelectItem>
))}
</SelectContent>
</Select>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<Input
autoFocus
required
placeholder="Enter task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Base Branch</Label>
<Select value={branch} onValueChange={setBranch} disabled={isEditMode || !repositoryId}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
!repositoryId
? 'Select repository first'
: branchesLoading
? 'Loading branches...'
: 'Select branch'
}
<div>
<Textarea
ref={descriptionRef}
placeholder="Write a prompt for the agent..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="min-h-[96px] max-h-[240px] resize-y overflow-auto"
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div>
{repositories.length === 0 && !repositoriesLoading ? (
<div className="flex items-center justify-center h-10 px-3 py-2 text-sm border border-input rounded-md bg-background">
<span className="text-muted-foreground mr-2">No repositories found.</span>
<Link href="/settings/workspace" className="text-primary hover:underline">
Add repository
</Link>
</div>
) : (
<Combobox
options={repositories.map((repo) => ({
value: repo.id,
label: repo.name,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="shrink-0">{repo.name}</span>
<Badge
variant="secondary"
className="text-xs text-muted-foreground max-w-[220px] min-w-0 truncate ml-auto"
title={formatUserHomePath(repo.local_path)}
>
{truncateRepoPath(repo.local_path)}
</Badge>
</span>
),
}))}
value={repositoryId}
onValueChange={(value) => {
setRepositoryId(value);
setBranch('');
}}
placeholder={repositoriesLoading ? 'Loading repositories...' : 'Select repository'}
searchPlaceholder="Search repositories..."
emptyMessage="No repository found."
disabled={isEditMode || repositoriesLoading}
dropdownLabel="Repository"
/>
</SelectTrigger>
<SelectContent>
{(branchesByRepo[repositoryId] ?? []).map((branchName) => (
<SelectItem key={branchName} value={branchName}>
{branchName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
A worktree will be created from this base branch.
</p>
</div>
</div>
<div className="space-y-2">
<Label>State</Label>
<Select value={taskState} onValueChange={(value) => setTaskState(value as Task['state'])}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="CREATED">Created</SelectItem>
<SelectItem value="SCHEDULING">Scheduling</SelectItem>
<SelectItem value="TODO">Todo</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
<SelectItem value="REVIEW">Review</SelectItem>
<SelectItem value="BLOCKED">Blocked</SelectItem>
<SelectItem value="WAITING_FOR_INPUT">Waiting for input</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
<SelectItem value="FAILED">Failed</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Agent Profile</Label>
<Select value={agentProfileId} onValueChange={setAgentProfileId}>
<SelectTrigger className="w-full">
<SelectValue
)}
</div>
<div>
<Combobox
options={(branchesByRepo[repositoryId] ?? []).map((branchObj) => {
const displayName = branchObj.type === 'remote' && branchObj.remote
? `${branchObj.remote}/${branchObj.name}`
: branchObj.name;
// Use display name as unique value since it includes remote prefix
return {
value: displayName,
label: displayName,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate">{displayName}</span>
<Badge variant={branchObj.type === 'local' ? 'default' : 'secondary'} className="text-xs">
{branchObj.type}
</Badge>
</span>
),
};
})}
value={branch}
onValueChange={(displayName) => {
// Store the full display name (e.g., "origin/master" or "master")
setBranch(displayName);
}}
placeholder={
agentProfilesLoading
? 'Loading agent profiles...'
: agentProfiles.length === 0
? 'No profiles available'
: 'Select agent profile'
!repositoryId
? 'Select repository first'
: branchesLoading
? 'Loading branches...'
: 'Select branch'
}
searchPlaceholder="Search branches..."
emptyMessage="No branch found."
disabled={isEditMode || !repositoryId || branchesLoading}
dropdownLabel="Branch"
/>
</SelectTrigger>
<SelectContent>
{agentProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Environment</Label>
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
</div>
<div>
{agentProfiles.length === 0 && !agentProfilesLoading ? (
<div className="flex items-center justify-center h-10 px-3 py-2 text-sm border border-input rounded-md bg-background">
<span className="text-muted-foreground mr-2">No agents found.</span>
<Link href="/settings/agents" className="text-primary hover:underline">
Add agent
</Link>
</div>
) : (
<Combobox
options={agentProfiles.map((profile) => ({
value: profile.id,
label: profile.label,
}))}
value={agentProfileId}
onValueChange={setAgentProfileId}
placeholder={
environmentsLoading
? 'Loading environments...'
: environments.length === 0
? 'No environments available'
: 'Select environment'
agentProfilesLoading
? 'Loading agents...'
: agentProfiles.length === 0
? 'No agents available'
: 'Select agent'
}
searchPlaceholder="Search agents..."
emptyMessage="No agent found."
dropdownLabel="Agent profile"
/>
</SelectTrigger>
<SelectContent>
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="space-y-2">
<Label>Executor</Label>
<Select value={executorId} onValueChange={setExecutorId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
executorsLoading
? 'Loading executors...'
: executors.length === 0
? 'No executors available'
: 'Select executor'
}
/>
</SelectTrigger>
<SelectContent>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
className="text-muted-foreground"
>
<IconSettings className="h-4 w-4 mr-1" />
{showAdvancedSettings ? 'Hide' : 'More Options'}
</Button>
</div>
{showAdvancedSettings && (
<div className="grid gap-4 md:grid-cols-2">
<div>
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
environmentsLoading
? 'Loading environments...'
: environments.length === 0
? 'No environments available'
: 'Select environment'
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Environment</SelectLabel>
{environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div>
<Select value={executorId} onValueChange={setExecutorId} disabled={isEditMode}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
executorsLoading
? 'Loading executors...'
: executors.length === 0
? 'No executors available'
: 'Select executor'
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Executor</SelectLabel>
{executors.map((executor) => (
<SelectItem key={executor.id} value={executor.id}>
{executor.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
<input
id="start-agent"
type="checkbox"
checked={startAgent}
onChange={(e) => setStartAgent(e.target.checked)}
disabled={isEditMode}
className="h-4 w-4 rounded border border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Label htmlFor="start-agent" className="cursor-pointer">
Start Agent
</Label>
</TooltipTrigger>
<TooltipContent>Start the agent on task creation.</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DialogFooter>
<DialogFooter className="border-t border-border pt-3">
<div className="flex flex-1 items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<input
id="start-agent"
type="checkbox"
checked={startAgent}
onChange={(e) => setStartAgent(e.target.checked)}
disabled={isEditMode}
className="h-4 w-4 rounded border border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Label htmlFor="start-agent" className="cursor-pointer">
Start Agent
</Label>
</TooltipTrigger>
<TooltipContent>Start the agent on task creation.</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{startAgent && (
<span className="text-xs text-muted-foreground">
A worktree will be created from this base branch.
</span>
)}
</div>
<DialogClose asChild>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
+4 -3
View File
@@ -26,11 +26,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { useAppStore } from '@/components/state-provider';
import type { Branch } from '@/lib/types/http';
type TaskTopBarProps = {
taskTitle?: string;
baseBranch?: string;
branches?: string[];
branches?: Branch[];
branchesLoading?: boolean;
onStartAgent?: () => void;
onStopAgent?: () => void;
@@ -155,8 +156,8 @@ const TaskTopBar = memo(function TaskTopBar({
</SelectItem>
) : (
branches.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
<SelectItem key={`${branch.type}-${branch.remote || ''}-${branch.name}`} value={branch.name}>
{branch.type === 'remote' && branch.remote ? `${branch.remote}/${branch.name}` : branch.name}
</SelectItem>
))
)}
+7 -1
View File
@@ -160,8 +160,14 @@ export type RepositoryPathValidationResponse = {
message?: string;
};
export type Branch = {
name: string;
type: 'local' | 'remote';
remote?: string; // remote name (e.g., "origin") for remote branches
};
export type RepositoryBranchesResponse = {
branches: string[];
branches: Branch[];
total: number;
};
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { formatUserHomePath, selectPreferredBranch, truncateRepoPath } from './utils';
describe('formatUserHomePath', () => {
it('replaces mac home path with tilde', () => {
expect(formatUserHomePath('/Users/alex/Projects/App')).toBe('~/Projects/App');
});
it('replaces linux home path with tilde', () => {
expect(formatUserHomePath('/home/alex/projects/app')).toBe('~/projects/app');
});
it('replaces windows home path with tilde', () => {
expect(formatUserHomePath('C:\\Users\\alex\\Projects\\App')).toBe('~/Projects/App');
});
it('leaves non-home paths unchanged', () => {
expect(formatUserHomePath('/var/tmp/project')).toBe('/var/tmp/project');
});
});
describe('truncateRepoPath', () => {
it('returns the path when under the limit', () => {
expect(truncateRepoPath('~/Projects/App', 40)).toBe('~/Projects/App');
});
it('prefers last segments for long paths', () => {
const path = '/Users/alex/Projects/Group/RepoName';
expect(truncateRepoPath(path, 22)).toBe('~/.../Group/RepoName');
});
it('falls back to last segment when space is tight', () => {
const path = '/Users/alex/Projects/Group/RepoName';
expect(truncateRepoPath(path, 10)).toBe('~/.../Name');
});
});
describe('selectPreferredBranch', () => {
it('selects origin/main first', () => {
const branches = [
{ name: 'main', type: 'local' },
{ name: 'main', type: 'remote', remote: 'origin' },
];
expect(selectPreferredBranch(branches)).toBe('origin/main');
});
it('falls back to origin/master', () => {
const branches = [
{ name: 'master', type: 'remote', remote: 'origin' },
{ name: 'main', type: 'local' },
];
expect(selectPreferredBranch(branches)).toBe('origin/master');
});
it('falls back to local main', () => {
const branches = [{ name: 'main', type: 'local' }];
expect(selectPreferredBranch(branches)).toBe('main');
});
it('returns null when no preferred branches exist', () => {
const branches = [{ name: 'develop', type: 'local' }];
expect(selectPreferredBranch(branches)).toBeNull();
});
});
+103
View File
@@ -20,3 +20,106 @@ export function generateUUID(): string {
return v.toString(16);
});
}
/**
* Extract organization/repository name from a repository URL or path.
* Supports GitHub URLs and local paths.
* @param repositoryUrl - The repository URL or local path
* @returns The org/repo-name format, or null if unable to extract
*/
export function extractRepoName(repositoryUrl: string | null | undefined): string | null {
if (!repositoryUrl) return null;
try {
// Try to parse as GitHub URL (https://github.com/org/repo or git@github.com:org/repo)
const githubHttpMatch = repositoryUrl.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (githubHttpMatch) {
return githubHttpMatch[1];
}
// Try to extract from local path (e.g., /path/to/org/repo or ~/org/repo)
const pathMatch = repositoryUrl.match(/([^/]+\/[^/]+)\/?$/);
if (pathMatch) {
return pathMatch[1];
}
return null;
} catch {
return null;
}
}
/**
* Normalize a local path for display, replacing the user home directory with "~".
* Supports macOS/Linux (/Users/name, /home/name) and Windows (C:\Users\name).
*/
export function formatUserHomePath(path: string): string {
if (!path) return path;
const normalized = path.replace(/\\/g, '/');
const macMatch = normalized.match(/^\/Users\/[^/]+(\/.*)?$/);
if (macMatch) {
return `~${macMatch[1] ?? ''}`;
}
const linuxMatch = normalized.match(/^\/home\/[^/]+(\/.*)?$/);
if (linuxMatch) {
return `~${linuxMatch[1] ?? ''}`;
}
const windowsMatch = normalized.match(/^[A-Za-z]:\/Users\/[^/]+(\/.*)?$/);
if (windowsMatch) {
return `~${windowsMatch[1] ?? ''}`;
}
return path;
}
/**
* Truncate a repository path for display, favoring the last segments.
*/
export function truncateRepoPath(path: string, maxLength = 28): string {
const displayPath = formatUserHomePath(path);
if (displayPath.length <= maxLength) return displayPath;
const normalizedPath = displayPath.replace(/\\/g, '/');
const hasHomePrefix = normalizedPath.startsWith('~/');
const hasRootPrefix = normalizedPath.startsWith('/');
const prefix = hasHomePrefix ? '~/' : hasRootPrefix ? '/' : '';
const parts = normalizedPath.replace(/^~\//, '').replace(/^\//, '').split('/').filter(Boolean);
if (parts.length === 0) return displayPath;
const lastTwo = parts.slice(-2).join('/');
let result = `${prefix}.../${lastTwo}`;
if (result.length <= maxLength) return result;
const lastOne = parts.slice(-1).join('/');
result = `${prefix}.../${lastOne}`;
if (result.length <= maxLength) return result;
const remaining = Math.max(1, maxLength - prefix.length - 4);
return `${prefix}.../${lastOne.slice(-remaining)}`;
}
type BranchSelectionCandidate = {
name: string;
type?: string;
remote?: string;
};
export function selectPreferredBranch(
branches: BranchSelectionCandidate[]
): string | null {
const originMain = branches.find(
(branch) => branch.type === 'remote' && branch.remote === 'origin' && branch.name === 'main'
);
if (originMain) return 'origin/main';
const originMaster = branches.find(
(branch) => branch.type === 'remote' && branch.remote === 'origin' && branch.name === 'master'
);
if (originMaster) return 'origin/master';
const localMain = branches.find((branch) => branch.type === 'local' && branch.name === 'main');
if (localMain) return 'main';
const localMaster = branches.find((branch) => branch.type === 'local' && branch.name === 'master');
if (localMaster) return 'master';
return null;
}
export const DEFAULT_LOCAL_ENVIRONMENT_KIND = 'local_pc';
export const DEFAULT_LOCAL_EXECUTOR_TYPE = 'local_pc';
+3 -2
View File
@@ -7,7 +7,7 @@
"build": "NODE_ENV=production next build",
"start": "NODE_ENV=production next start",
"lint": "eslint",
"test": "echo \"No tests yet\" && exit 0"
"test": "vitest run"
},
"dependencies": {
"@kandev/theme": "workspace:*",
@@ -44,6 +44,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^1.6.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});