Skip to content

Commit

Permalink
Add support for directories to the Files tab (#8093)
Browse files Browse the repository at this point in the history
We currently only show files from the exec log on the Files tab. This
change adds support for showing directories too.
<img width="1183" alt="Screenshot 2024-12-19 at 1 13 54 PM"
src="https://github.com/user-attachments/assets/0f590108-a324-4871-a682-c4727020c106"
/>
  • Loading branch information
siggisim authored Dec 19, 2024
1 parent eaf8ef1 commit e6e0901
Show file tree
Hide file tree
Showing 2 changed files with 226 additions and 19 deletions.
2 changes: 2 additions & 0 deletions app/invocation/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -824,8 +824,10 @@ ts_library(
"//app/service:rpc_service",
"//proto:build_event_stream_ts_proto",
"//proto:spawn_ts_proto",
"@npm//@types/long",
"@npm//@types/react",
"@npm//@types/varint",
"@npm//long",
"@npm//lucide-react",
"@npm//react",
"@npm//tslib",
Expand Down
243 changes: 224 additions & 19 deletions app/invocation/invocation_file_card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import { tools } from "../../proto/spawn_ts_proto";
import format from "../format/format";
import error_service from "../errors/error_service";
import * as varint from "varint";
import { Download, FileIcon } from "lucide-react";
import { Download, FileIcon, Folder, FolderOpen } from "lucide-react";
import DigestComponent from "../components/digest/digest";
import Link from "../components/link/link";
import Long from "long";

interface Props {
model: InvocationModel;
Expand All @@ -22,17 +23,22 @@ interface State {
loading: boolean;
sort: string;
direction: "asc" | "desc";
limit: number;
fileLimit: number;
directoryLimit: number;
log: tools.protos.ExecLogEntry[] | undefined;
directoryToFileLimit: Map<string, number>;
}

export default class InvocationExecLogCardComponent extends React.Component<Props, State> {
const pageSize = 100;
export default class InvocationFileCardComponent extends React.Component<Props, State> {
state: State = {
loading: true,
sort: "size",
direction: "desc",
limit: 100,
fileLimit: pageSize,
directoryLimit: pageSize,
log: undefined,
directoryToFileLimit: new Map<string, number>(),
};

timeoutRef?: number;
Expand Down Expand Up @@ -111,7 +117,7 @@ export default class InvocationExecLogCardComponent extends React.Component<Prop
}
}

sort(a: tools.protos.ExecLogEntry, b: tools.protos.ExecLogEntry): number {
compareFiles(a: tools.protos.ExecLogEntry, b: tools.protos.ExecLogEntry): number {
let first = this.state.direction == "asc" ? a : b;
let second = this.state.direction == "asc" ? b : a;

Expand All @@ -123,6 +129,38 @@ export default class InvocationExecLogCardComponent extends React.Component<Prop
}
}

compareDirectories(a: tools.protos.ExecLogEntry, b: tools.protos.ExecLogEntry): number {
let first = this.state.direction == "asc" ? a : b;
let second = this.state.direction == "asc" ? b : a;

switch (this.state.sort) {
case "size":
return +this.sumFileSizes(first?.directory?.files) - +this.sumFileSizes(second?.directory?.files);
default:
return (first.directory?.path || "").localeCompare(second.directory?.path || "");
}
}

sumFileSizes(files: Array<tools.protos.ExecLogEntry.File> | undefined) {
return (
(files || []).map((f) => f.digest?.sizeBytes || 0).reduce((a, b) => new Long(+a + +b), Long.ZERO) || Long.ZERO
);
}

hashFileHashes(files: Array<tools.protos.ExecLogEntry.File> | undefined) {
// Just do something simple here that gives us a unique string that changes when one file changes.
return (files || [])
.map((f) => f.digest?.hash || "")
.reduce((a, b) => {
var hash = "";
for (var i = 0; i < Math.max(a.length, b.length); i++) {
let char = (a.charCodeAt(i) + b.charCodeAt(i)) % 36;
hash += String.fromCharCode(char < 26 ? char + 97 : char + 22);
}
return hash;
}, "");
}

handleSortDirectionChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
direction: event.target.value as "asc" | "desc",
Expand All @@ -135,22 +173,30 @@ export default class InvocationExecLogCardComponent extends React.Component<Prop
});
}

handleMoreClicked() {
this.setState({ limit: this.state.limit + 100 });
handleMoreFilesClicked() {
this.setState({ fileLimit: this.state.fileLimit + pageSize });
}

handleAllFilesClicked() {
this.setState({ fileLimit: Number.MAX_SAFE_INTEGER });
}

handleMoreDirectoriesClicked() {
this.setState({ directoryLimit: this.state.directoryLimit + pageSize });
}

handleAllClicked() {
this.setState({ limit: Number.MAX_SAFE_INTEGER });
handleAllDirectoriesClicked() {
this.setState({ directoryLimit: Number.MAX_SAFE_INTEGER });
}

getFileLink(entry: tools.protos.ExecLogEntry) {
if (!entry.file?.digest) {
getFileLink(file: tools.protos.ExecLogEntry.File | null | undefined) {
if (!file?.digest) {
return undefined;
}

return `/code/buildbuddy-io/buildbuddy/?bytestream_url=${encodeURIComponent(
this.props.model.getBytestreamURL(entry.file.digest)
)}&invocation_id=${this.props.model.getInvocationId()}&filename=${entry.file.path}`;
this.props.model.getBytestreamURL(file.digest)
)}&invocation_id=${this.props.model.getInvocationId()}&filename=${file.path}`;
}

render() {
Expand Down Expand Up @@ -180,7 +226,27 @@ export default class InvocationExecLogCardComponent extends React.Component<Prop

return true;
})
.sort(this.sort.bind(this));
.sort(this.compareFiles.bind(this));

const directories = this.state.log
.filter((l) => {
if (l.type != "directory") {
return false;
}
if (
this.props.filter != "" &&
!l.directory?.files.find((f) =>
(l.directory?.path + "/" + f.path).toLowerCase().includes(this.props.filter.toLowerCase())
) &&
!l.directory?.files.find((f) => f.digest?.hash.includes(this.props.filter)) &&
!l.directory?.path.toLowerCase().includes(this.props.filter.toLowerCase())
) {
return false;
}

return true;
})
.sort(this.compareDirectories.bind(this));

return (
<div>
Expand Down Expand Up @@ -225,8 +291,8 @@ export default class InvocationExecLogCardComponent extends React.Component<Prop
</div>
<div>
<div className="invocation-execution-table">
{files.slice(0, this.state.limit).map((file) => (
<Link key={file.id} className="invocation-execution-row" href={this.getFileLink(file)}>
{files.slice(0, this.state.fileLimit).map((file) => (
<Link key={file.id} className="invocation-execution-row" href={this.getFileLink(file?.file)}>
<div className="invocation-execution-row-image">
<FileIcon className="icon" />
</div>
Expand All @@ -243,10 +309,149 @@ export default class InvocationExecLogCardComponent extends React.Component<Prop
</Link>
))}
</div>
{files.length > this.state.limit && (
{files.length > this.state.fileLimit && (
<div className="more-buttons">
<OutlinedButton onClick={this.handleMoreFilesClicked.bind(this)}>See more files</OutlinedButton>
<OutlinedButton onClick={this.handleAllFilesClicked.bind(this)}>See all files</OutlinedButton>
</div>
)}
</div>
</div>
</div>
<div className={`card expanded`}>
<div className="content">
<div className="invocation-content-header">
<div className="title">
Directories ({format.formatWithCommas(directories.length)}){" "}
<Download className="download-exec-log-button" onClick={() => this.downloadLog()} />
</div>
<div className="invocation-sort-controls">
<span className="invocation-sort-title">Sort by</span>
<Select onChange={this.handleSortChange.bind(this)} value={this.state.sort}>
<Option value="path">File path</Option>
<Option value="size">File size</Option>
</Select>
<span className="group-container">
<div>
<input
id="direction-asc-dir"
checked={this.state.direction == "asc"}
onChange={this.handleSortDirectionChange.bind(this)}
value="asc"
name="dirDirection"
type="radio"
/>
<label htmlFor="direction-asc-dir">Asc</label>
</div>
<div>
<input
id="direction-desc-dir"
checked={this.state.direction == "desc"}
onChange={this.handleSortDirectionChange.bind(this)}
value="desc"
name="dirDirection"
type="radio"
/>
<label htmlFor="direction-desc-dir">Desc</label>
</div>
</span>
</div>
</div>
<div>
<div className="invocation-execution-table">
{directories.slice(0, this.state.directoryLimit).map((entry) => {
let path = entry.directory?.path || "";
let fileLimit = this.state.directoryToFileLimit.get(path) || 0;
return (
<>
<div
className="invocation-execution-row clickable"
onClick={() => {
this.state.directoryToFileLimit.set(path, !fileLimit ? pageSize : 0);
this.forceUpdate();
}}>
<div className="invocation-execution-row-image">
{fileLimit ? <FolderOpen className="icon" /> : <Folder className="icon" />}
</div>
<div>
<div className="invocation-execution-row-header">
<div>
<DigestComponent
digest={{
sizeBytes: this.sumFileSizes(entry.directory?.files),
hash: this.hashFileHashes(entry.directory?.files),
}}
expanded={true}
/>
</div>
</div>
<div className="invocation-execution-row-header-status">{path}</div>
</div>
</div>
{(Boolean(fileLimit) || this.props.filter) &&
entry.directory?.files
.filter(
(e) =>
(path + "/" + e.path).toLowerCase().includes(this.props.filter) ||
e.digest?.hash.includes(this.props.filter)
)
.sort((a, b) =>
this.compareFiles(
new tools.protos.ExecLogEntry({ file: a }),
new tools.protos.ExecLogEntry({ file: b })
)
)
.slice(0, Math.max(fileLimit, this.props.filter ? pageSize : 0))
.map((file) => (
<Link
key={file.path}
className="invocation-execution-row nested"
href={this.getFileLink(file)}>
<div className="invocation-execution-row-image">
<FileIcon className="icon" />
</div>
<div>
<div className="invocation-execution-row-header">
{file?.digest && (
<div>
<DigestComponent digest={file.digest} expanded={true} />
</div>
)}
</div>
<div className="invocation-execution-row-header-status">{file?.path}</div>
</div>
</Link>
))}
{Boolean(fileLimit) && (entry.directory?.files.length || 0) > fileLimit && (
<div className="more-buttons nested">
<OutlinedButton
onClick={() => {
this.state.directoryToFileLimit.set(path, fileLimit + pageSize);
this.forceUpdate();
}}>
See more files
</OutlinedButton>
<OutlinedButton
onClick={() => {
this.state.directoryToFileLimit.set(path, Number.MAX_SAFE_INTEGER);
this.forceUpdate();
}}>
See all files
</OutlinedButton>
</div>
)}
</>
);
})}
</div>
{directories.length > this.state.directoryLimit && (
<div className="more-buttons">
<OutlinedButton onClick={this.handleMoreClicked.bind(this)}>See more files</OutlinedButton>
<OutlinedButton onClick={this.handleAllClicked.bind(this)}>See all files</OutlinedButton>
<OutlinedButton onClick={this.handleMoreDirectoriesClicked.bind(this)}>
See more directories
</OutlinedButton>
<OutlinedButton onClick={this.handleAllDirectoriesClicked.bind(this)}>
See all directories
</OutlinedButton>
</div>
)}
</div>
Expand Down

0 comments on commit e6e0901

Please sign in to comment.