Open files and replace the file row menu with real actions
This commit is contained in:
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 = ?",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user