feat: simplify task creation
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+1450
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user