Skip to content

Commit 9178ae5

Browse files
committed
Release v0.0.66
Desktop v0.0.66
1 parent ca90e11 commit 9178ae5

File tree

20 files changed

+1392
-206
lines changed

20 files changed

+1392
-206
lines changed

bun.lockb

429 Bytes
Binary file not shown.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "21st-desktop",
3-
"version": "0.0.65",
3+
"version": "0.0.66",
44
"private": true,
55
"description": "1Code - UI for parallel work with AI agents",
66
"author": {
@@ -119,6 +119,8 @@
119119
"devDependencies": {
120120
"@electron-toolkit/preload": "^3.0.1",
121121
"@electron-toolkit/utils": "^4.0.0",
122+
"@electron/rebuild": "^4.0.3",
123+
"@tailwindcss/container-queries": "^0.1.1",
122124
"@types/better-sqlite3": "^7.6.13",
123125
"@types/diff": "^8.0.0",
124126
"@types/node": "^20.17.50",
@@ -130,7 +132,6 @@
130132
"drizzle-kit": "^0.31.8",
131133
"electron": "~39.4.0",
132134
"electron-builder": "^25.1.8",
133-
"@electron/rebuild": "^4.0.3",
134135
"electron-vite": "^3.0.0",
135136
"postcss": "^8.5.1",
136137
"tailwindcss": "^3.4.17",

src/main/lib/trpc/routers/codex.ts

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const providerSessions = new Map<string, CodexProviderSession>()
9292
type ActiveCodexStream = {
9393
runId: string
9494
controller: AbortController
95+
cancelRequested: boolean
9596
}
9697

9798
const activeStreams = new Map<string, ActiveCodexStream>()
@@ -1323,6 +1324,7 @@ export const codexRouter = router({
13231324
return observable<any>((emit) => {
13241325
const existingStream = activeStreams.get(input.subChatId)
13251326
if (existingStream) {
1327+
existingStream.cancelRequested = true
13261328
existingStream.controller.abort()
13271329
// Ensure old run cannot continue emitting after supersede.
13281330
cleanupProvider(input.subChatId)
@@ -1332,6 +1334,7 @@ export const codexRouter = router({
13321334
activeStreams.set(input.subChatId, {
13331335
runId: input.runId,
13341336
controller: abortController,
1337+
cancelRequested: false,
13351338
})
13361339

13371340
let isActive = true
@@ -1384,6 +1387,43 @@ export const codexRouter = router({
13841387
extractPromptFromStoredMessage(lastMessage) === input.prompt
13851388

13861389
let messagesForStream = existingMessages
1390+
const isAuthoritativeRun = () => {
1391+
const currentStream = activeStreams.get(input.subChatId)
1392+
return !currentStream || currentStream.runId === input.runId
1393+
}
1394+
1395+
const persistSubChatMessages = (messages: any[]) => {
1396+
if (!isAuthoritativeRun()) {
1397+
return false
1398+
}
1399+
1400+
db.update(subChats)
1401+
.set({
1402+
messages: JSON.stringify(messages),
1403+
updatedAt: new Date(),
1404+
})
1405+
.where(eq(subChats.id, input.subChatId))
1406+
.run()
1407+
return true
1408+
}
1409+
1410+
const cleanAssistantMessageForPersistence = (message: any) => {
1411+
if (!message || message.role !== "assistant") return message
1412+
if (!Array.isArray(message.parts)) return message
1413+
1414+
const cleanedParts = message.parts.filter(
1415+
(part: any) => part?.state !== "input-streaming",
1416+
)
1417+
1418+
if (cleanedParts.length === 0) {
1419+
return null
1420+
}
1421+
1422+
return {
1423+
...message,
1424+
parts: cleanedParts,
1425+
}
1426+
}
13871427

13881428
if (!isDuplicatePrompt) {
13891429
const userMessage = {
@@ -1481,15 +1521,24 @@ export const codexRouter = router({
14811521

14821522
return { model: metadataModel }
14831523
},
1484-
onFinish: ({ messages }) => {
1524+
onFinish: ({ responseMessage, isContinuation }) => {
14851525
try {
1486-
db.update(subChats)
1487-
.set({
1488-
messages: JSON.stringify(messages),
1489-
updatedAt: new Date(),
1490-
})
1491-
.where(eq(subChats.id, input.subChatId))
1492-
.run()
1526+
const cleanedResponseMessage =
1527+
cleanAssistantMessageForPersistence(responseMessage)
1528+
1529+
if (!cleanedResponseMessage) {
1530+
persistSubChatMessages(messagesForStream)
1531+
return
1532+
}
1533+
1534+
const messagesToPersist = [
1535+
...(isContinuation
1536+
? messagesForStream.slice(0, -1)
1537+
: messagesForStream),
1538+
cleanedResponseMessage,
1539+
]
1540+
1541+
persistSubChatMessages(messagesToPersist)
14931542
} catch (error) {
14941543
console.error("[codex] Failed to persist messages:", error)
14951544
}
@@ -1498,7 +1547,6 @@ export const codexRouter = router({
14981547
})
14991548

15001549
const reader = uiStream.getReader()
1501-
15021550
while (true) {
15031551
const { done, value } = await reader.read()
15041552
if (done) break
@@ -1530,7 +1578,13 @@ export const codexRouter = router({
15301578
safeEmit({ type: "finish" })
15311579
safeComplete()
15321580
} finally {
1533-
if (activeStreams.get(input.subChatId)?.runId === input.runId) {
1581+
const activeStream = activeStreams.get(input.subChatId)
1582+
if (activeStream?.runId === input.runId) {
1583+
const shouldCleanupProvider =
1584+
abortController.signal.aborted || activeStream.cancelRequested
1585+
if (shouldCleanupProvider) {
1586+
cleanupProvider(input.subChatId)
1587+
}
15341588
activeStreams.delete(input.subChatId)
15351589
}
15361590
}
@@ -1540,8 +1594,9 @@ export const codexRouter = router({
15401594
isActive = false
15411595
abortController.abort()
15421596

1543-
if (activeStreams.get(input.subChatId)?.runId === input.runId) {
1544-
activeStreams.delete(input.subChatId)
1597+
const activeStream = activeStreams.get(input.subChatId)
1598+
if (activeStream?.runId === input.runId) {
1599+
activeStream.cancelRequested = true
15451600
}
15461601
}
15471602
})
@@ -1564,10 +1619,8 @@ export const codexRouter = router({
15641619
return { cancelled: false, ignoredStale: true }
15651620
}
15661621

1622+
activeStream.cancelRequested = true
15671623
activeStream.controller.abort()
1568-
// Authoritative stop for Codex: force teardown of provider session.
1569-
cleanupProvider(input.subChatId)
1570-
activeStreams.delete(input.subChatId)
15711624

15721625
return { cancelled: true, ignoredStale: false }
15731626
}),

src/main/lib/trpc/routers/files.ts

Lines changed: 107 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { z } from "zod"
22
import { router, publicProcedure } from "../index"
3-
import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises"
4-
import { join, relative, basename, extname } from "node:path"
5-
import { app } from "electron"
3+
import { readdir, stat, readFile, writeFile, mkdir, rename as fsRename, rm } from "node:fs/promises"
4+
import { join, relative, basename, extname, dirname, resolve, isAbsolute } from "node:path"
5+
import { app, shell } from "electron"
66
import { watch } from "node:fs"
77
import { observable } from "@trpc/server/observable"
88

@@ -65,10 +65,43 @@ interface FileEntry {
6565
type: "file" | "folder"
6666
}
6767

68-
// Cache for file and folder listings
68+
// Cache for file and folder listings (bounded LRU)
69+
const MAX_CACHE_ENTRIES = 20
6970
const fileListCache = new Map<string, { entries: FileEntry[]; timestamp: number }>()
7071
const CACHE_TTL = 5000 // 5 seconds
7172

73+
/**
74+
* Validate that a path doesn't contain path traversal attacks.
75+
* Checks for null bytes and ensures the resolved path stays within the expected parent.
76+
*/
77+
function validatePathSafe(targetPath: string, allowedParent?: string): void {
78+
if (targetPath.includes("\0")) {
79+
throw new Error("Path contains invalid characters")
80+
}
81+
if (!isAbsolute(targetPath)) {
82+
throw new Error("Path must be absolute")
83+
}
84+
const resolved = resolve(targetPath)
85+
if (allowedParent) {
86+
const resolvedParent = resolve(allowedParent)
87+
if (!resolved.startsWith(resolvedParent + "/") && resolved !== resolvedParent) {
88+
throw new Error("Path escapes allowed directory")
89+
}
90+
}
91+
}
92+
93+
function validateFileName(name: string): void {
94+
if (name.includes("/") || name.includes("\\")) {
95+
throw new Error("File name cannot contain path separators")
96+
}
97+
if (name.includes("\0")) {
98+
throw new Error("File name contains invalid characters")
99+
}
100+
if (name === "." || name === "..") {
101+
throw new Error("Invalid file name")
102+
}
103+
}
104+
72105
/**
73106
* Recursively scan a directory and return all file and folder paths
74107
*/
@@ -135,8 +168,21 @@ async function getEntryList(projectPath: string): Promise<FileEntry[]> {
135168
}
136169

137170
const entries = await scanDirectory(projectPath)
138-
fileListCache.set(projectPath, { entries, timestamp: now })
139171

172+
// Evict oldest entries if cache is full
173+
if (fileListCache.size >= MAX_CACHE_ENTRIES) {
174+
let oldest: string | null = null
175+
let oldestTime = Infinity
176+
for (const [key, val] of fileListCache) {
177+
if (val.timestamp < oldestTime) {
178+
oldestTime = val.timestamp
179+
oldest = key
180+
}
181+
}
182+
if (oldest) fileListCache.delete(oldest)
183+
}
184+
185+
fileListCache.set(projectPath, { entries, timestamp: now })
140186
return entries
141187
}
142188

@@ -146,14 +192,18 @@ async function getEntryList(projectPath: string): Promise<FileEntry[]> {
146192
function filterEntries(
147193
entries: FileEntry[],
148194
query: string,
149-
limit: number
195+
limit: number,
196+
typeFilter?: "file" | "folder",
150197
): Array<{ id: string; label: string; path: string; repository: string; type: "file" | "folder" }> {
151198
const queryLower = query.toLowerCase()
152199

153-
// Filter entries that match the query
200+
// Filter entries that match the query and optional type filter
154201
let filtered = entries
202+
if (typeFilter) {
203+
filtered = filtered.filter((entry) => entry.type === typeFilter)
204+
}
155205
if (query) {
156-
filtered = entries.filter((entry) => {
206+
filtered = filtered.filter((entry) => {
157207
const name = basename(entry.path).toLowerCase()
158208
const pathLower = entry.path.toLowerCase()
159209
return name.includes(queryLower) || pathLower.includes(queryLower)
@@ -198,7 +248,7 @@ function filterEntries(
198248
})
199249

200250
// Limit results
201-
const limited = filtered.slice(0, Math.min(limit, 200))
251+
const limited = filtered.slice(0, Math.min(limit, 5000))
202252

203253
// Map to expected format with type
204254
return limited.map((entry) => ({
@@ -219,11 +269,12 @@ export const filesRouter = router({
219269
z.object({
220270
projectPath: z.string(),
221271
query: z.string().default(""),
222-
limit: z.number().min(1).max(200).default(50),
272+
limit: z.number().min(1).max(5000).default(50),
273+
typeFilter: z.enum(["file", "folder"]).optional(),
223274
})
224275
)
225276
.query(async ({ input }) => {
226-
const { projectPath, query, limit } = input
277+
const { projectPath, query, limit, typeFilter } = input
227278

228279
if (!projectPath) {
229280
return []
@@ -239,16 +290,9 @@ export const filesRouter = router({
239290

240291
// Get entry list (cached or fresh scan)
241292
const entries = await getEntryList(projectPath)
242-
243-
// Debug: log folder count
244-
const folderCount = entries.filter(e => e.type === "folder").length
245-
const fileCount = entries.filter(e => e.type === "file").length
246-
console.log(`[files] Scanned ${projectPath}: ${folderCount} folders, ${fileCount} files`)
247293

248294
// Filter and sort by query
249-
const results = filterEntries(entries, query, limit)
250-
console.log(`[files] Query "${query}": returning ${results.length} results, folders: ${results.filter(r => r.type === "folder").length}`)
251-
return results
295+
return filterEntries(entries, query, limit, typeFilter)
252296
} catch (error) {
253297
console.error(`[files] Error searching files:`, error)
254298
return []
@@ -407,8 +451,15 @@ export const filesRouter = router({
407451

408452
// Generate filename with timestamp
409453
const finalFilename = filename || `pasted_${Date.now()}.txt`
454+
455+
// Validate filename doesn't contain path separators or null bytes
456+
validateFileName(finalFilename)
457+
410458
const filePath = join(pastedDir, finalFilename)
411459

460+
// Ensure the resolved path stays within the pasted directory
461+
validatePathSafe(filePath, pastedDir)
462+
412463
// Write file
413464
await writeFile(filePath, text, "utf-8")
414465

@@ -420,4 +471,41 @@ export const filesRouter = router({
420471
size: text.length,
421472
}
422473
}),
474+
475+
/**
476+
* Rename a file or folder
477+
*/
478+
renameFile: publicProcedure
479+
.input(z.object({
480+
absolutePath: z.string(),
481+
newName: z.string().min(1),
482+
}))
483+
.mutation(async ({ input }) => {
484+
const { absolutePath, newName } = input
485+
486+
validatePathSafe(absolutePath)
487+
validateFileName(newName)
488+
489+
const dir = dirname(absolutePath)
490+
const newPath = join(dir, newName)
491+
492+
// Ensure the new path stays in the same directory
493+
validatePathSafe(newPath, dir)
494+
495+
await fsRename(absolutePath, newPath)
496+
return { success: true, newPath }
497+
}),
498+
499+
/**
500+
* Delete a file or folder (move to trash)
501+
*/
502+
deleteFile: publicProcedure
503+
.input(z.object({
504+
absolutePath: z.string(),
505+
}))
506+
.mutation(async ({ input }) => {
507+
validatePathSafe(input.absolutePath)
508+
await shell.trashItem(input.absolutePath)
509+
return { success: true }
510+
}),
423511
})

src/renderer/components/open-in-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const JETBRAINS_OPTIONS: AppOption[] = [
8585

8686
const ALL_APP_OPTIONS = [...APP_OPTIONS, ...VSCODE_OPTIONS, ...JETBRAINS_OPTIONS];
8787

88-
function getAppOption(id: ExternalApp): AppOption {
88+
export function getAppOption(id: ExternalApp): AppOption {
8989
return ALL_APP_OPTIONS.find((app) => app.id === id) ?? APP_OPTIONS[1];
9090
}
9191

0 commit comments

Comments
 (0)