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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"line-column-path": "^3.0.0",
"lodash": "^4.17.21",
"lowdb": "^7.0.1",
"lucide-react": "^0.555.0",
"nanoid": "^5.1.6",
"node-pty": "1.1.0-beta30",
"react": "^19.1.1",
Expand Down
195 changes: 194 additions & 1 deletion apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,85 @@
import { basename } from "node:path";
import { existsSync } from "node:fs";
import { access } from "node:fs/promises";
import { basename, join } from "node:path";
import type { BrowserWindow } from "electron";
import { dialog } from "electron";
import { db } from "main/lib/db";
import type { Project } from "main/lib/db/schemas";
import { nanoid } from "nanoid";
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getGitRoot } from "../workspaces/utils/git";
import { assignRandomColor } from "./utils/colors";

// Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode
// Allows most valid Git repo names while avoiding path traversal characters
const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/;

/**
* Extracts and validates a repository name from a git URL.
* Handles HTTP/HTTPS URLs, SSH-style URLs (git@host:user/repo), and edge cases.
*/
function extractRepoName(urlInput: string): string | null {
// Normalize: trim whitespace and strip trailing slashes
let normalized = urlInput.trim().replace(/\/+$/, "");

if (!normalized) return null;

let repoSegment: string | undefined;

// Try parsing as HTTP/HTTPS URL first
try {
const parsed = new URL(normalized);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
// Get pathname and strip query/hash (URL constructor handles this)
const pathname = parsed.pathname;
// Get the last segment of the path
repoSegment = pathname.split("/").filter(Boolean).pop();
}
} catch {
// Not a valid URL, try SSH-style parsing
}

// Fallback to SSH-style parsing (git@github.com:user/repo.git)
if (!repoSegment) {
// Handle SSH format: git@host:path or just path segments
const colonIndex = normalized.indexOf(":");
if (colonIndex !== -1 && !normalized.includes("://")) {
// SSH-style: take everything after the colon
normalized = normalized.slice(colonIndex + 1);
}
// Split by '/' and get the last segment
repoSegment = normalized.split("/").filter(Boolean).pop();
}

if (!repoSegment) return null;

// Strip query string and hash if present (for edge cases)
repoSegment = repoSegment.split("?")[0].split("#")[0];

// Remove trailing .git extension
repoSegment = repoSegment.replace(/\.git$/, "");

// Decode percent-encoded characters
try {
repoSegment = decodeURIComponent(repoSegment);
} catch {
// Invalid encoding, continue with raw value
}

// Trim any remaining whitespace or special characters at boundaries
repoSegment = repoSegment.trim();

// Validate against safe filename regex
if (!repoSegment || !SAFE_REPO_NAME_REGEX.test(repoSegment)) {
return null;
}

return repoSegment;
}

export const createProjectsRouter = (window: BrowserWindow) => {
return router({
getRecents: publicProcedure.query((): Project[] => {
Expand Down Expand Up @@ -73,6 +143,129 @@ export const createProjectsRouter = (window: BrowserWindow) => {
};
}),

cloneRepo: publicProcedure
.input(
z.object({
url: z.string().url(),
// Trim and convert empty/whitespace strings to undefined
targetDirectory: z
.string()
.trim()
.optional()
.transform((v) => (v && v.length > 0 ? v : undefined)),
}),
)
Comment on lines +146 to +157
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

SSH-style URLs will fail Zod's .url() validation.

The z.string().url() validator only accepts URLs with valid protocols (http, https, etc.). SSH-style URLs like git@github.com:user/repo.git will fail validation before reaching extractRepoName, even though that function is designed to handle them.

Consider using a custom validation or a more permissive schema:

 .input(
   z.object({
-    url: z.string().url(),
+    url: z.string().min(1, "Repository URL is required"),
     // Trim and convert empty/whitespace strings to undefined
     targetDirectory: z
       .string()
       .trim()
       .optional()
       .transform((v) => (v && v.length > 0 ? v : undefined)),
   }),
 )

Alternatively, add a custom refinement that validates both URL formats:

url: z.string().refine(
  (val) => {
    // Accept standard URLs
    try { new URL(val); return true; } catch {}
    // Accept SSH-style URLs (git@host:path)
    return /^[\w.-]+@[\w.-]+:.+/.test(val);
  },
  "Invalid repository URL"
),
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/projects/projects.ts around lines 146 to
157, the input schema uses z.string().url() which rejects SSH-style repo strings
like "git@github.com:user/repo.git"; replace that validator with a
z.string().refine that accepts either a valid URL (using new URL(...) in a
try/catch) or an SSH-style pattern (e.g. /^[\w.-]+@[\w.-]+:.+/), and provide a
clear error message like "Invalid repository URL"; keep the existing
.trim().optional().transform(...) behavior for targetDirectory.

.mutation(async ({ input }) => {
try {
let targetDir = input.targetDirectory;

if (!targetDir) {
const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory", "createDirectory"],
title: "Select Clone Destination",
});

// User canceled - return canceled state (not an error)
if (result.canceled || result.filePaths.length === 0) {
return { canceled: true as const, success: false as const };
}

targetDir = result.filePaths[0];
}

const repoName = extractRepoName(input.url);
if (!repoName) {
return {
canceled: false as const,
success: false as const,
error: "Invalid repository URL",
};
}

const clonePath = join(targetDir, repoName);

// Check if we already have a project for this path
const existingProject = db.data.projects.find(
(p) => p.mainRepoPath === clonePath,
);

if (existingProject) {
// Verify the filesystem path still exists
try {
await access(clonePath);
// Directory exists - update lastOpenedAt and return existing project
await db.update((data) => {
const p = data.projects.find(
(p) => p.id === existingProject.id,
);
if (p) {
p.lastOpenedAt = Date.now();
}
});
return {
canceled: false as const,
success: true as const,
project: existingProject,
};
} catch {
// Directory is missing - remove the stale project record and continue with clone
await db.update((data) => {
const index = data.projects.findIndex(
(p) => p.id === existingProject.id,
);
if (index !== -1) {
data.projects.splice(index, 1);
}
});
// Continue to normal creation flow below
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Check if target directory already exists (but not our project)
if (existsSync(clonePath)) {
return {
canceled: false as const,
success: false as const,
error: `A folder named "${repoName}" already exists at this location. Please choose a different destination.`,
};
}

// Clone the repository
const git = simpleGit();
await git.clone(input.url, clonePath);

// Create new project
const name = basename(clonePath);
const project: Project = {
id: nanoid(),
mainRepoPath: clonePath,
name,
color: assignRandomColor(),
tabOrder: null,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};

await db.update((data) => {
data.projects.push(project);
});

return {
canceled: false as const,
success: true as const,
project,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
canceled: false as const,
success: false as const,
error: `Failed to clone repository: ${errorMessage}`,
};
}
}),

update: publicProcedure
.input(
z.object({
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/lib/trpc/routers/window.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { homedir } from "node:os";
import type { BrowserWindow } from "electron";
import { publicProcedure, router } from "..";

Expand Down Expand Up @@ -33,6 +34,10 @@ export const createWindowRouter = (window: BrowserWindow) => {
getPlatform: publicProcedure.query(() => {
return process.platform;
}),

getHomeDir: publicProcedure.query(() => {
return homedir();
}),
});
};

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export async function MainWindow() {
title: productName,
width,
height,
minWidth: 400,
minHeight: 400,
show: false,
center: true,
movable: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Input } from "@superset/ui/input";
import { Kbd, KbdGroup } from "@superset/ui/kbd";
import { useMemo, useState } from "react";
import { HiMagnifyingGlass } from "react-icons/hi2";
import {
formatKeysForDisplay,
getHotkeysByCategory,
type HotkeyCategory,
type HotkeyDefinition,
} from "shared/hotkeys";

function useIsMac(): boolean {
return useMemo(() => {
const platform = navigator.platform?.toUpperCase() ?? "";
const userAgent = navigator.userAgent?.toUpperCase() ?? "";
return platform.includes("MAC") || userAgent.includes("MAC");
}, []);
}

const CATEGORY_ORDER: HotkeyCategory[] = [
"Workspace",
"Terminal",
"Layout",
"Window",
"Help",
];

function HotkeyRow({
hotkey,
isEven,
}: {
hotkey: HotkeyDefinition;
isEven: boolean;
}) {
const keys = formatKeysForDisplay(hotkey.keys);

return (
<div
className={`flex items-center justify-between py-3 px-4 ${
isEven ? "bg-accent/20" : ""
}`}
>
<span className="text-sm text-foreground">{hotkey.label}</span>
<KbdGroup>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</div>
);
}

/**
* Consolidate individual workspace jump shortcuts (1-9) into a single entry
*/
function consolidateWorkspaceJumps(
hotkeys: HotkeyDefinition[],
): HotkeyDefinition[] {
const workspaceJumpPattern = /^Switch to Workspace \d$/;
const hasWorkspaceJumps = hotkeys.some((h) =>
workspaceJumpPattern.test(h.label),
);

if (!hasWorkspaceJumps) return hotkeys;

const filtered = hotkeys.filter((h) => !workspaceJumpPattern.test(h.label));
filtered.unshift({
keys: "meta+1-9",
label: "Switch to Workspace 1-9",
category: "Workspace",
});

return filtered;
}

export function KeyboardShortcutsSettings() {
const [searchQuery, setSearchQuery] = useState("");
const hotkeysByCategory = getHotkeysByCategory();
const isMac = useIsMac();
const modifierKey = isMac ? "⌘" : "Ctrl";

// Flatten and consolidate all hotkeys
const allHotkeys = CATEGORY_ORDER.flatMap((category) =>
consolidateWorkspaceJumps(hotkeysByCategory[category]),
);

// Filter based on search query
const filteredHotkeys = searchQuery
? allHotkeys.filter((hotkey) =>
hotkey.label.toLowerCase().includes(searchQuery.toLowerCase()),
)
: allHotkeys;

return (
<div className="p-6 w-full max-w-3xl">
{/* Header */}
<div className="mb-6">
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
<p className="text-sm text-muted-foreground mt-1">
View all available keyboard shortcuts. Press{" "}
<Kbd className="mx-1">{modifierKey}</Kbd>
<Kbd>?</Kbd> to open this page anytime.
</p>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

{/* Search */}
<div className="relative mb-6">
<HiMagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-accent/30 border-transparent focus:border-accent"
/>
</div>

{/* Table */}
<div className="rounded-lg border border-border overflow-hidden">
{/* Table Header */}
<div className="flex items-center justify-between py-2 px-4 bg-accent/10 border-b border-border">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Command
</span>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Shortcut
</span>
</div>

{/* Table Body */}
<div className="max-h-[calc(100vh-320px)] overflow-y-auto">
{filteredHotkeys.length > 0 ? (
filteredHotkeys.map((hotkey, index) => (
<HotkeyRow
key={hotkey.keys}
hotkey={hotkey}
isEven={index % 2 === 0}
/>
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
No shortcuts found matching "{searchQuery}"
</div>
)}
</div>
</div>
</div>
);
}
Loading