Skip to content

Commit ae97719

Browse files
authored
Merge pull request #112 from httpsphl/feat/file-kind-colors
feat(files): a color and an icon per kind of file
2 parents 4b5364a + a43942c commit ae97719

6 files changed

Lines changed: 206 additions & 24 deletions

File tree

‎app/src/features/files/FilesPanel.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Callout } from "../../ui/Callout";
1111
import { SidePanel } from "../../ui/SidePanel";
1212
import { BotAvatar } from "../bots/BotAvatar";
1313
import { FilePreview } from "./FilePreview";
14-
import { fileIcon } from "./kinds";
14+
import { fileKind, kindIcon } from "./kinds";
1515
import type { BotFiles } from "./useBotFiles";
1616

1717
export function FilesPanel({
@@ -124,7 +124,8 @@ function FileRow({
124124
onOpen(path: string): void;
125125
}) {
126126
const t = useT().files;
127-
const Icon = fileIcon(file.mediaType, file.name);
127+
const kind = fileKind(file.mediaType, file.name);
128+
const Icon = kindIcon(kind);
128129
return (
129130
<li>
130131
<button
@@ -133,7 +134,9 @@ function FileRow({
133134
title={file.path}
134135
className="flex w-full min-w-0 items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-sunken"
135136
>
136-
<Icon aria-hidden size={18} className="shrink-0 text-muted" />
137+
<span aria-hidden className="file-tile" data-kind={kind}>
138+
<Icon size={18} />
139+
</span>
137140
<span className="min-w-0 flex-1">
138141
<span className="flex items-center gap-2">
139142
<span className="truncate font-medium text-sm">{file.name}</span>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, test } from "vitest";
2+
import { fileKind } from "./kinds";
3+
4+
const OCTET = "application/octet-stream";
5+
6+
describe("fileKind", () => {
7+
test("office files by their media type", () => {
8+
expect(
9+
fileKind(
10+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
11+
"report.docx",
12+
),
13+
).toBe("word");
14+
expect(
15+
fileKind("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "q3.xlsx"),
16+
).toBe("sheet");
17+
expect(fileKind("text/csv", "plan.csv")).toBe("sheet");
18+
expect(fileKind("application/pdf", "contract.pdf")).toBe("pdf");
19+
});
20+
21+
test("old and open formats by extension, though they come as octet-stream", () => {
22+
expect(fileKind(OCTET, "Old Letter.DOC")).toBe("word");
23+
expect(fileKind(OCTET, "budget.ods")).toBe("sheet");
24+
expect(fileKind(OCTET, "pitch.ppt")).toBe("slides");
25+
expect(fileKind(OCTET, "backup.7z")).toBe("archive");
26+
expect(fileKind(OCTET, "call.m4a")).toBe("audio");
27+
});
28+
29+
test("media, code, text and the rest", () => {
30+
expect(fileKind("image/png", "chart.png")).toBe("image");
31+
expect(fileKind("audio/mpeg", "memo.mp3")).toBe("audio");
32+
expect(fileKind("video/mp4", "demo.mp4")).toBe("video");
33+
expect(fileKind("text/plain", "main.rs")).toBe("code");
34+
expect(fileKind("application/json", "data.json")).toBe("code");
35+
expect(fileKind("text/markdown", "notes.md")).toBe("text");
36+
expect(fileKind(OCTET, "blob.bin")).toBe("other");
37+
expect(fileKind(OCTET, ".docx")).toBe("other");
38+
});
39+
});

‎app/src/features/files/kinds.ts‎

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
// What a file is, from its media type: the icon in the list and how the
2-
// preview shows it.
1+
// What a file is, from its name and media type: the icon and color in the
2+
// list and how the preview shows it.
33

44
import {
55
File,
@@ -9,8 +9,10 @@ import {
99
FileImage,
1010
FileSpreadsheet,
1111
FileText,
12+
FileType,
1213
FileVideo,
1314
type LucideIcon,
15+
Presentation,
1416
} from "lucide-react";
1517

1618
export type PreviewKind = "image" | "pdf" | "markdown" | "text" | "none";
@@ -31,28 +33,95 @@ export function previewKind(mediaType: string): PreviewKind {
3133
return mediaType.startsWith("text/") || TEXT.includes(mediaType) ? "text" : "none";
3234
}
3335

34-
export function fileIcon(mediaType: string, name: string): LucideIcon {
35-
if (mediaType.startsWith("image/")) {
36-
return FileImage;
37-
}
38-
if (mediaType.startsWith("audio/")) {
39-
return FileAudio;
36+
/** The family a file belongs to; each has its own icon and color (files.css). */
37+
export type FileKind =
38+
| "pdf"
39+
| "word"
40+
| "sheet"
41+
| "slides"
42+
| "image"
43+
| "audio"
44+
| "video"
45+
| "archive"
46+
| "code"
47+
| "text"
48+
| "other";
49+
50+
// By extension first: the folder scan sends old Office, OpenDocument and most
51+
// media files as application/octet-stream.
52+
const BY_EXTENSION: Record<string, FileKind> = {
53+
pdf: "pdf",
54+
doc: "word",
55+
docx: "word",
56+
odt: "word",
57+
rtf: "word",
58+
xls: "sheet",
59+
xlsx: "sheet",
60+
ods: "sheet",
61+
csv: "sheet",
62+
ppt: "slides",
63+
pptx: "slides",
64+
odp: "slides",
65+
zip: "archive",
66+
rar: "archive",
67+
"7z": "archive",
68+
mp3: "audio",
69+
wav: "audio",
70+
m4a: "audio",
71+
ogg: "audio",
72+
mp4: "video",
73+
mov: "video",
74+
webm: "video",
75+
};
76+
77+
const CODE = /\.(ts|tsx|js|jsx|py|rs|css|html?|json|xml|ya?ml|toml|sql|sh|ps1|bat)$/i;
78+
79+
export function fileKind(mediaType: string, name: string): FileKind {
80+
const dot = name.lastIndexOf(".");
81+
const known = dot > 0 ? BY_EXTENSION[name.slice(dot + 1).toLowerCase()] : undefined;
82+
if (known) {
83+
return known;
4084
}
41-
if (mediaType.startsWith("video/")) {
42-
return FileVideo;
85+
if (mediaType === "application/pdf") {
86+
return "pdf";
4387
}
44-
if (mediaType === "application/zip") {
45-
return FileArchive;
88+
if (mediaType.includes("wordprocessing")) {
89+
return "word";
4690
}
4791
if (mediaType === "text/csv" || mediaType.includes("spreadsheet")) {
48-
return FileSpreadsheet;
92+
return "sheet";
4993
}
50-
if (
51-
mediaType === "text/html" ||
52-
TEXT.includes(mediaType) ||
53-
/\.(ts|tsx|js|py|rs|css)$/i.test(name)
54-
) {
55-
return FileCode;
94+
if (mediaType.includes("presentation")) {
95+
return "slides";
5696
}
57-
return mediaType === "application/octet-stream" ? File : FileText;
97+
for (const kind of ["image", "audio", "video"] as const) {
98+
if (mediaType.startsWith(`${kind}/`)) {
99+
return kind;
100+
}
101+
}
102+
if (mediaType === "application/zip") {
103+
return "archive";
104+
}
105+
if (mediaType === "text/html" || TEXT.includes(mediaType) || CODE.test(name)) {
106+
return "code";
107+
}
108+
return mediaType === "application/octet-stream" ? "other" : "text";
109+
}
110+
111+
const ICONS: Record<FileKind, LucideIcon> = {
112+
pdf: FileText,
113+
word: FileType,
114+
sheet: FileSpreadsheet,
115+
slides: Presentation,
116+
image: FileImage,
117+
audio: FileAudio,
118+
video: FileVideo,
119+
archive: FileArchive,
120+
code: FileCode,
121+
text: FileText,
122+
other: File,
123+
};
124+
125+
export function kindIcon(kind: FileKind): LucideIcon {
126+
return ICONS[kind];
58127
}

‎app/src/files.css‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* The tile in front of each file in a bot's files panel: one color per kind
2+
of file, so a Word document, a sheet and a PDF tell apart at a glance. The
3+
kind also has its own icon, and the name says the extension. */
4+
5+
.file-tile {
6+
--kind: var(--muted);
7+
display: grid;
8+
flex-shrink: 0;
9+
place-items: center;
10+
width: 2rem;
11+
height: 2rem;
12+
border-radius: 0.5rem;
13+
color: var(--kind);
14+
background: color-mix(in srgb, var(--kind) 14%, transparent);
15+
}
16+
17+
.file-tile[data-kind="pdf"] {
18+
--kind: #d13a2f;
19+
}
20+
.file-tile[data-kind="word"] {
21+
--kind: #2b62c9;
22+
}
23+
.file-tile[data-kind="sheet"] {
24+
--kind: #1e7d45;
25+
}
26+
.file-tile[data-kind="slides"] {
27+
--kind: #c8561b;
28+
}
29+
.file-tile[data-kind="image"] {
30+
--kind: #8546c9;
31+
}
32+
.file-tile[data-kind="audio"],
33+
.file-tile[data-kind="video"] {
34+
--kind: #c2367a;
35+
}
36+
.file-tile[data-kind="archive"] {
37+
--kind: #93641a;
38+
}
39+
.file-tile[data-kind="code"] {
40+
--kind: #127a80;
41+
}
42+
43+
[data-theme="dark"] .file-tile {
44+
background: color-mix(in srgb, var(--kind) 20%, transparent);
45+
}
46+
[data-theme="dark"] .file-tile[data-kind="pdf"] {
47+
--kind: #ff7066;
48+
}
49+
[data-theme="dark"] .file-tile[data-kind="word"] {
50+
--kind: #74a6ff;
51+
}
52+
[data-theme="dark"] .file-tile[data-kind="sheet"] {
53+
--kind: #55c985;
54+
}
55+
[data-theme="dark"] .file-tile[data-kind="slides"] {
56+
--kind: #ff9a5c;
57+
}
58+
[data-theme="dark"] .file-tile[data-kind="image"] {
59+
--kind: #bf8cff;
60+
}
61+
[data-theme="dark"] .file-tile[data-kind="audio"],
62+
[data-theme="dark"] .file-tile[data-kind="video"] {
63+
--kind: #ff7ab8;
64+
}
65+
[data-theme="dark"] .file-tile[data-kind="archive"] {
66+
--kind: #e0ad5a;
67+
}
68+
[data-theme="dark"] .file-tile[data-kind="code"] {
69+
--kind: #4fc8cf;
70+
}

‎app/src/index.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@import "./panels.css";
1111
@import "./sidebar.css";
1212
@import "./effort.css";
13+
@import "./files.css";
1314
@import "./motion-less.css";
1415
@import "./paused.css";
1516
@import "./offscreen.css";

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,7 @@ Layout, como um app de mensagens:
777777
- **Sugestão de bot** (10.2): um cartão no chat do chefe, "<chefe> sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido.
778778
- **Pedido de permissão** (10.1): um cartão no chat, "<bot> quer <ação>" ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, "<bot> escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz "<bot> não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto.
779779
- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel.
780-
- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou.
780+
- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou.
781781

782782
### 15.2 Comandos Tauri
783783

0 commit comments

Comments
 (0)