Open files and replace the file row menu with real actions

This commit is contained in:
ZacharyZhang-NY committed 2026-05-28 22:36:31 -04:00
1 parent d2695202b5
commit 69ae525cb7
10 files changed
+81 -10

No files matched your search

@@ -196,6 +196,15 @@ export function registerIpcHandlers(deps: IpcDeps): void {
} }
}); });
ipcMain.handle(IPC_CHANNELS.openFile, async (_event, input) => {
try {
await files.openFile(input);
return ok(null);
} catch (error) {
return fail("file_open_failed", error instanceof Error ? error.message : String(error));
}
});
ipcMain.handle(IPC_CHANNELS.pickDirectory, async () => { ipcMain.handle(IPC_CHANNELS.pickDirectory, async () => {
try { try {
const result = await dialog.showOpenDialog(deps.mainWindow, { properties: ["openDirectory", "createDirectory"] }); const result = await dialog.showOpenDialog(deps.mainWindow, { properties: ["openDirectory", "createDirectory"] });
@@ -2,8 +2,8 @@ import { createHash } from "node:crypto";
import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { basename, extname, join } from "node:path"; import { basename, extname, join } from "node:path";
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
import { dialog } from "electron"; import { dialog, shell } from "electron";
import type { ImportFileInput } from "../../shared/commands"; import type { ImportFileInput, OpenFileInput } from "../../shared/commands";
import { FILE_SCAN_STATE, type FileScanState } from "../../shared/domain"; import { FILE_SCAN_STATE, type FileScanState } from "../../shared/domain";
import { createId } from "../../shared/ids"; import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service"; import { AuditService } from "./audit-service";
@@ -117,6 +117,16 @@ export class FileService {
return id; return id;
} }
async openFile(input: OpenFileInput): Promise<void> {
const row = this.db.prepare(
"select storage_path as storagePath, scan_state as scanState from file_objects where id = ? and org_id = ?",
).get(input.fileId, input.orgId) as { storagePath: string; scanState: string } | undefined;
if (!row) throw new Error("File was not found.");
if (row.scanState !== FILE_SCAN_STATE.APPROVED) throw new Error("Blocked files cannot be opened.");
const failure = await shell.openPath(row.storagePath);
if (failure) throw new Error(failure);
}
setPinned(orgId: string, fileId: string, pinned: boolean): void { setPinned(orgId: string, fileId: string, pinned: boolean): void {
const result = this.db.prepare( const result = this.db.prepare(
"update file_objects set pinned = ?, updated_at = ? where id = ? and org_id = ?", "update file_objects set pinned = ?, updated_at = ? where id = ? and org_id = ?",
+1
View File
@@ -43,6 +43,7 @@ const api: DesktopApi = {
createScheduledPost: (input) => invoke(IPC_CHANNELS.createScheduledPost, input), createScheduledPost: (input) => invoke(IPC_CHANNELS.createScheduledPost, input),
setScheduledPostState: (input) => invoke(IPC_CHANNELS.setScheduledPostState, input), setScheduledPostState: (input) => invoke(IPC_CHANNELS.setScheduledPostState, input),
importFile: (input) => invoke(IPC_CHANNELS.importFile, input), importFile: (input) => invoke(IPC_CHANNELS.importFile, input),
openFile: (input) => invoke(IPC_CHANNELS.openFile, input),
pickDirectory: () => invoke(IPC_CHANNELS.pickDirectory), pickDirectory: () => invoke(IPC_CHANNELS.pickDirectory),
setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input), setFilePinned: (input) => invoke(IPC_CHANNELS.setFilePinned, input),
createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input), createMigrationReport: (input) => invoke(IPC_CHANNELS.createMigrationReport, input),
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button, Icon, Segmented } from "../shared/ui"; import { Button, Icon, Segmented } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions"; import { TopbarActions } from "../shared/TopbarActions";
import type { FileObjectRecord } from "../../../shared/domain";
import type { PageProps } from "./types"; import type { PageProps } from "./types";
import { activeOrgId, formatBytes } from "./types"; import { activeOrgId, formatBytes } from "./types";
import { FilesTree } from "./files/FilesTree"; import { FilesTree } from "./files/FilesTree";
@@ -40,6 +41,13 @@ export function FilesPage({ snapshot, actions }: PageProps) {
if (!result.ok) throw new Error(result.error.message); if (!result.ok) throw new Error(result.error.message);
}); });
}; };
const openFile = (file: FileObjectRecord) => {
void window.agentCompany.openFile({ orgId, fileId: file.id }).then((result) => {
if (!result.ok) throw new Error(result.error.message);
});
};
const togglePin = (file: FileObjectRecord) =>
void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }));
return ( return (
<div style={{ display: "grid", gridTemplateColumns: "minmax(160px, 200px) minmax(0, 1fr)", gap: 24, height: "100%", minHeight: 0, padding: "28px 32px 0" }}> <div style={{ display: "grid", gridTemplateColumns: "minmax(160px, 200px) minmax(0, 1fr)", gap: 24, height: "100%", minHeight: 0, padding: "28px 32px 0" }}>
@@ -83,7 +91,8 @@ export function FilesPage({ snapshot, actions }: PageProps) {
file={file} file={file}
ownerName={resolveOwnerName(file, employees, agents)} ownerName={resolveOwnerName(file, employees, agents)}
grantCount={fileGrantCount(file.id, fileAccessGrants)} grantCount={fileGrantCount(file.id, fileAccessGrants)}
onUnpin={() => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: false }))} onOpen={() => openFile(file)}
onUnpin={() => togglePin(file)}
/> />
))} ))}
</div> </div>
@@ -101,7 +110,8 @@ export function FilesPage({ snapshot, actions }: PageProps) {
grants={fileAccessGrants} grants={fileAccessGrants}
employees={employees} employees={employees}
agents={agents} agents={agents}
onTogglePin={(file) => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))} onOpen={openFile}
onTogglePin={togglePin}
/> />
) : ( ) : (
<FilesGrid <FilesGrid
@@ -109,7 +119,8 @@ export function FilesPage({ snapshot, actions }: PageProps) {
grants={fileAccessGrants} grants={fileAccessGrants}
employees={employees} employees={employees}
agents={agents} agents={agents}
onTogglePin={(file) => void actions.run(() => window.agentCompany.setFilePinned({ orgId, fileId: file.id, pinned: !file.pinned }))} onOpen={openFile}
onTogglePin={togglePin}
/> />
)} )}
</div> </div>
@@ -14,12 +14,14 @@ export function FilesGrid({
grants, grants,
employees, employees,
agents, agents,
onOpen,
onTogglePin, onTogglePin,
}: { }: {
files: FileObjectRecord[]; files: FileObjectRecord[];
grants: FileAccessGrantRecord[]; grants: FileAccessGrantRecord[];
employees: EmployeeRecord[]; employees: EmployeeRecord[];
agents: AgentRecord[]; agents: AgentRecord[];
onOpen: (file: FileObjectRecord) => void;
onTogglePin: (file: FileObjectRecord) => void; onTogglePin: (file: FileObjectRecord) => void;
}) { }) {
return ( return (
@@ -30,6 +32,7 @@ export function FilesGrid({
file={file} file={file}
ownerName={resolveOwnerName(file, employees, agents)} ownerName={resolveOwnerName(file, employees, agents)}
grantCount={fileGrantCount(file.id, grants)} grantCount={fileGrantCount(file.id, grants)}
onOpen={() => onOpen(file)}
onUnpin={() => onTogglePin(file)} onUnpin={() => onTogglePin(file)}
/> />
))} ))}
@@ -41,15 +44,23 @@ export function PinnedCard({
file, file,
ownerName, ownerName,
grantCount, grantCount,
onOpen,
onUnpin, onUnpin,
}: { }: {
file: FileObjectRecord; file: FileObjectRecord;
ownerName: string; ownerName: string;
grantCount: number; grantCount: number;
onOpen: () => void;
onUnpin: () => void; onUnpin: () => void;
}) { }) {
const canOpen = file.scanState === "approved";
return ( return (
<div <div
className={canOpen ? "clickable-card" : ""}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
onClick={canOpen ? onOpen : undefined}
onKeyDown={canOpen ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
style={{ style={{
border: "1px solid var(--border)", border: "1px solid var(--border)",
borderRadius: 10, borderRadius: 10,
@@ -59,6 +70,7 @@ export function PinnedCard({
flexDirection: "column", flexDirection: "column",
gap: 10, gap: 10,
minWidth: 0, minWidth: 0,
cursor: canOpen ? "pointer" : "default",
}} }}
> >
<div className="row" style={{ gap: 10 }}> <div className="row" style={{ gap: 10 }}>
@@ -66,7 +78,7 @@ export function PinnedCard({
<button <button
type="button" type="button"
aria-label={file.pinned ? "Unpin" : "Pin"} aria-label={file.pinned ? "Unpin" : "Pin"}
onClick={onUnpin} onClick={(event) => { event.stopPropagation(); onUnpin(); }}
style={{ marginLeft: "auto", padding: 4, border: 0, background: "transparent", color: file.pinned ? "var(--accent)" : "var(--text-4)", cursor: "pointer" }} style={{ marginLeft: "auto", padding: 4, border: 0, background: "transparent", color: file.pinned ? "var(--accent)" : "var(--text-4)", cursor: "pointer" }}
> >
<Icon name="pin" size={14} /> <Icon name="pin" size={14} />
@@ -1,4 +1,5 @@
import { Avatar, IconButton, Pill } from "../../shared/ui"; import { Avatar, IconButton, Pill } from "../../shared/ui";
import { MenuRow, Popover } from "../../shared/Popover";
import type { import type {
AgentRecord, AgentRecord,
EmployeeRecord, EmployeeRecord,
@@ -16,12 +17,14 @@ export function FilesTable({
grants, grants,
employees, employees,
agents, agents,
onOpen,
onTogglePin, onTogglePin,
}: { }: {
files: FileObjectRecord[]; files: FileObjectRecord[];
grants: FileAccessGrantRecord[]; grants: FileAccessGrantRecord[];
employees: EmployeeRecord[]; employees: EmployeeRecord[];
agents: AgentRecord[]; agents: AgentRecord[];
onOpen: (file: FileObjectRecord) => void;
onTogglePin: (file: FileObjectRecord) => void; onTogglePin: (file: FileObjectRecord) => void;
}) { }) {
return ( return (
@@ -36,6 +39,7 @@ export function FilesTable({
owner={resolveOwnerName(file, employees, agents)} owner={resolveOwnerName(file, employees, agents)}
grantCount={fileGrantCount(file.id, grants)} grantCount={fileGrantCount(file.id, grants)}
last={index === files.length - 1} last={index === files.length - 1}
onOpen={() => onOpen(file)}
onTogglePin={() => onTogglePin(file)} onTogglePin={() => onTogglePin(file)}
/> />
))} ))}
@@ -48,17 +52,24 @@ function FileRow({
owner, owner,
grantCount, grantCount,
last, last,
onOpen,
onTogglePin, onTogglePin,
}: { }: {
file: FileObjectRecord; file: FileObjectRecord;
owner: string; owner: string;
grantCount: number; grantCount: number;
last: boolean; last: boolean;
onOpen: () => void;
onTogglePin: () => void; onTogglePin: () => void;
}) { }) {
const canOpen = file.scanState === "approved";
return ( return (
<div <div
className="table-row" className={`table-row ${canOpen ? "clickable" : ""}`.trim()}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
onClick={canOpen ? onOpen : undefined}
onKeyDown={canOpen ? (event) => { if (event.key === "Enter") onOpen(); } : undefined}
style={{ gridTemplateColumns: ROW_COLUMNS, borderBottom: last ? "none" : "1px solid var(--border-soft)" }} style={{ gridTemplateColumns: ROW_COLUMNS, borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
> >
<div className="row" style={{ gap: 10, minWidth: 0 }}> <div className="row" style={{ gap: 10, minWidth: 0 }}>
@@ -77,12 +88,19 @@ function FileRow({
<span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{owner}</span> <span style={{ color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{owner}</span>
</div> </div>
<div className="row" style={{ gap: 6, minWidth: 0 }}> <div className="row" style={{ gap: 6, minWidth: 0 }}>
<Pill tone={file.scanState === "approved" ? "green" : "red"}>{file.scanState === "approved" ? "Clean" : "Blocked"}</Pill> <Pill tone={canOpen ? "green" : "red"}>{canOpen ? "Clean" : "Blocked"}</Pill>
<span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{grantCount}</span> <span className="mono" style={{ color: "var(--text-3)", fontSize: 11 }}>{grantCount}</span>
</div> </div>
<div style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(file.updatedAt ?? file.createdAt)}</div> <div style={{ color: "var(--text-3)", fontSize: 12 }}>{formatRelative(file.updatedAt ?? file.createdAt)}</div>
<div style={{ textAlign: "right" }}> <div style={{ textAlign: "right" }} onClick={(event) => event.stopPropagation()}>
<IconButton icon={file.pinned ? "pin" : "more"} label={file.pinned ? "Unpin" : "More"} onClick={onTogglePin} /> <Popover trigger={({ toggle }) => <IconButton icon="more" label={`Actions for ${file.name}`} onClick={toggle} />}>
{(close) => (
<>
{canOpen && <MenuRow onClick={() => { onOpen(); close(); }}>Open</MenuRow>}
<MenuRow onClick={() => { onTogglePin(); close(); }}>{file.pinned ? "Unpin" : "Pin"}</MenuRow>
</>
)}
</Popover>
</div> </div>
</div> </div>
); );
@@ -519,6 +519,8 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.table-row:last-child { border-bottom: 0; } .table-row:last-child { border-bottom: 0; }
.table-row.clickable { cursor: pointer; } .table-row.clickable { cursor: pointer; }
.table-row.clickable:hover { background: var(--hover); } .table-row.clickable:hover { background: var(--hover); }
.clickable-card { transition: border-color 0.1s ease; }
.clickable-card:hover { border-color: var(--border-strong); }
.link-button { .link-button {
border: 0; border: 0;
+5
View File
@@ -248,6 +248,11 @@ export interface ImportFileInput {
orgId: string; orgId: string;
} }
export interface OpenFileInput {
orgId: string;
fileId: string;
}
export interface CreateMigrationReportInput { export interface CreateMigrationReportInput {
orgId: string; orgId: string;
sourceDatabasePath: string; sourceDatabasePath: string;
@@ -26,6 +26,7 @@ import type {
DeleteOrganizationInput, DeleteOrganizationInput,
EditMessageInput, EditMessageInput,
ImportFileInput, ImportFileInput,
OpenFileInput,
ImportSkillDirectoryInput, ImportSkillDirectoryInput,
GrantRoleInput, GrantRoleInput,
LaunchProviderSignInInput, LaunchProviderSignInInput,
@@ -97,6 +98,7 @@ export interface DesktopApi {
createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>; createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>;
setScheduledPostState(input: SetScheduledPostStateInput): Promise<DesktopResult<AppSnapshot>>; setScheduledPostState(input: SetScheduledPostStateInput): Promise<DesktopResult<AppSnapshot>>;
importFile(input: ImportFileInput): Promise<DesktopResult<string | null>>; importFile(input: ImportFileInput): Promise<DesktopResult<string | null>>;
openFile(input: OpenFileInput): Promise<DesktopResult<null>>;
pickDirectory(): Promise<DesktopResult<string | null>>; pickDirectory(): Promise<DesktopResult<string | null>>;
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>; setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>; createMigrationReport(input: CreateMigrationReportInput): Promise<DesktopResult<AppSnapshot>>;
+1
View File
@@ -35,6 +35,7 @@ export const IPC_CHANNELS = {
createScheduledPost: "agentcompany:create-scheduled-post", createScheduledPost: "agentcompany:create-scheduled-post",
setScheduledPostState: "agentcompany:set-scheduled-post-state", setScheduledPostState: "agentcompany:set-scheduled-post-state",
importFile: "agentcompany:import-file", importFile: "agentcompany:import-file",
openFile: "agentcompany:open-file",
pickDirectory: "agentcompany:pick-directory", pickDirectory: "agentcompany:pick-directory",
setFilePinned: "agentcompany:set-file-pinned", setFilePinned: "agentcompany:set-file-pinned",
createMigrationReport: "agentcompany:create-migration-report", createMigrationReport: "agentcompany:create-migration-report",