diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel
index 6723de7abff..90873d8fed5 100644
--- a/client/web/BUILD.bazel
+++ b/client/web/BUILD.bazel
@@ -1841,6 +1841,7 @@ ts_project(
"//:node_modules/react-circular-progressbar",
"//:node_modules/react-dom",
"//:node_modules/react-grid-layout",
+ "//:node_modules/react-icons",
"//:node_modules/react-router-dom",
"//:node_modules/react-spring",
"//:node_modules/react-sticky-box",
diff --git a/client/web/src/repo/RepoRevisionSidebarFileTree.module.scss b/client/web/src/repo/RepoRevisionSidebarFileTree.module.scss
index 6fcc4c1e7fc..962173149ff 100644
--- a/client/web/src/repo/RepoRevisionSidebarFileTree.module.scss
+++ b/client/web/src/repo/RepoRevisionSidebarFileTree.module.scss
@@ -1,5 +1,54 @@
+.icon-container {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
.icon {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
+
+.test-indicator {
+ border-radius: 100%;
+ width: 0.5rem;
+ height: 0.5rem;
+ margin-left: -0.65rem;
+ margin-bottom: 0.15rem;
+ margin-right: 0.15rem;
+ background-color: var(--gray-05);
+ align-self: end;
+}
+
+.blue {
+ color: var(--blue);
+}
+
+.pink {
+ color: var(--pink);
+}
+
+.yellow {
+ color: var(--yellow);
+}
+
+.red {
+ color: var(--red);
+}
+
+.gray {
+ color: var(--gray-05);
+}
+
+.green {
+ color: var(--green);
+}
+
+.cyan {
+ color: var(--cyan);
+}
+
+.default-icon {
+ color: var(--gray-07);
+}
diff --git a/client/web/src/repo/RepoRevisionSidebarFileTree.tsx b/client/web/src/repo/RepoRevisionSidebarFileTree.tsx
index 34676b46cab..1f6c574c5ab 100644
--- a/client/web/src/repo/RepoRevisionSidebarFileTree.tsx
+++ b/client/web/src/repo/RepoRevisionSidebarFileTree.tsx
@@ -27,7 +27,9 @@ import {
import type { FileTreeEntriesResult, FileTreeEntriesVariables } from '../graphql-operations'
+import { FILE_ICONS, FileExtension } from './constants'
import { FocusableTree, type FocusableTreeProps } from './RepoRevisionSidebarFocusableTree'
+import { getFileInfo } from './utils'
import styles from './RepoRevisionSidebarFileTree.module.scss'
@@ -389,6 +391,8 @@ function renderNode({
const { entry, error, dotdot, name } = element
const submodule = entry?.submodule
const url = entry?.url
+ const fileInfo = getFileInfo(name)
+ const fileIcon = FILE_ICONS.get(fileInfo.extension)
if (error) {
return
@@ -470,12 +474,25 @@ function renderNode({
}
}}
>
-
- {name}
+
+ {fileInfo.extension !== FileExtension.DEFAULT ? (
+
+ ) : (
+
+ )}
+ {fileInfo.isTest &&
}
+ {name}
+
)
}
diff --git a/client/web/src/repo/constants.ts b/client/web/src/repo/constants.ts
index 7644b985eef..26712599f25 100644
--- a/client/web/src/repo/constants.ts
+++ b/client/web/src/repo/constants.ts
@@ -1,3 +1,54 @@
+import { ComponentType } from 'react'
+
+import { CiSettings, CiTextAlignLeft, CiWarning } from 'react-icons/ci'
+import { FaCss3Alt, FaJava, FaSass } from 'react-icons/fa'
+import { GoDatabase, GoTerminal } from 'react-icons/go'
+import { PiFilePngLight } from 'react-icons/pi'
+import {
+ SiApachegroovy,
+ SiAssemblyscript,
+ SiC,
+ SiClojure,
+ SiCoffeescript,
+ SiCplusplus,
+ SiCsharp,
+ SiDart,
+ SiDocker,
+ SiFortran,
+ SiFsharp,
+ SiGit,
+ SiGo,
+ SiGraphql,
+ SiHaskell,
+ SiHtml5,
+ SiJavascript,
+ SiJpeg,
+ SiJulia,
+ SiKotlin,
+ SiLua,
+ SiMarkdown,
+ SiNixos,
+ SiNpm,
+ SiOcaml,
+ SiPerl,
+ SiPhp,
+ SiPython,
+ SiR,
+ SiReact,
+ SiRuby,
+ SiRust,
+ SiScala,
+ SiSvelte,
+ SiSvg,
+ SiSwift,
+ SiTypescript,
+ SiVisualbasic,
+ SiZig,
+} from 'react-icons/si'
+import { VscJson } from 'react-icons/vsc'
+
+import styles from './RepoRevisionSidebarFileTree.module.scss'
+
export const LogsPageTabs = {
COMMANDS: 0,
SYNCLOGS: 1,
@@ -13,3 +64,160 @@ export enum CodeHostType {
AZUREDEVOPS = 'azureDevOps',
OTHER = 'other',
}
+
+export enum FileExtension {
+ ASSEMBLY = 'asm',
+ BASH = 'sh',
+ BASIC = 'vb',
+ C = 'c',
+ CLOJURE_CLJ = 'clj',
+ CLOJURE_CLJS = 'cljs',
+ CLOJURE_CLJR = 'cljr',
+ CLOJURE_CLJC = 'cljc',
+ CLOJURE_EDN = 'edn',
+ COFFEE = 'coffee',
+ CPP = 'cc',
+ CSHARP = 'cs',
+ CSS = 'css',
+ DART = 'dart',
+ DEFAULT = 'default',
+ DOCKERFILE = 'Dockerfile',
+ DOCKERIGNORE = 'dockerignore',
+ FORTRAN_F = 'f',
+ FORTRAN_FOR = 'for',
+ FORTRAN_FTN = 'ftn',
+ FSHARP = 'fs',
+ FSI = 'fsi',
+ FSX = 'fsx',
+ GITIGNORE = 'gitignore',
+ GITATTRIBUTES = 'gitattributes',
+ GO = 'go',
+ GOMOD = 'mod',
+ GOSUM = 'sum',
+ GRAPHQL = 'graphql',
+ GROOVY = 'groovy',
+ HASKELL = 'hs',
+ HTML = 'html',
+ JAVA = 'java',
+ JAVASCRIPT = 'js',
+ JPG = 'jpg',
+ JPEG = 'jpeg',
+ JSON = 'json',
+ JSX = 'jsx',
+ JULIA = 'jl',
+ KOTLIN = 'kt',
+ LOCKFILE = 'lock',
+ LUA = 'lua',
+ MARKDOWN = 'md',
+ NCL = 'ncl',
+ NIX = 'nix',
+ NPM = 'npmrc',
+ OCAML = 'ml',
+ PHP = 'php',
+ PERL = 'pl',
+ PERL_PM = 'pm',
+ PNG = 'png',
+ POWERSHELL_PS1 = 'ps1',
+ POWERSHELL_PSM1 = 'psm1',
+ PYTHON = 'py',
+ R = 'r',
+ R_CAP = 'R',
+ RUBY = 'rb',
+ RUST = 'rs',
+ SCALA = 'scala',
+ SASS = 'scss',
+ SQL = 'sql',
+ SVELTE = 'svelte',
+ SVG = 'svg',
+ SWIFT = 'swift',
+ TEST = 'test',
+ TYPESCRIPT = 'ts',
+ TSX = 'tsx',
+ TEXT = 'txt',
+ YAML = 'yaml',
+ YML = 'yml',
+ ZIG = 'zig',
+}
+
+type CustomIcon = ComponentType<{ className?: string }>
+interface IconInfo {
+ icon: CustomIcon
+ iconClass: string
+}
+/*
+ * We use the react-icons package instead of material design icons for two reasons:
+ * 1) Many of mdi's programming language icons will be deprecated soon.
+ * 2) They are missing quite a few icons that are needed when displaying file types.
+ */
+export const FILE_ICONS: Map = new Map([
+ [FileExtension.ASSEMBLY, { icon: SiAssemblyscript, iconClass: styles.defaultIcon }],
+ [FileExtension.BASH, { icon: GoTerminal, iconClass: styles.defaultIcon }],
+ [FileExtension.BASIC, { icon: SiVisualbasic, iconClass: styles.defaultIcon }],
+ [FileExtension.C, { icon: SiC, iconClass: styles.blue }],
+ [FileExtension.CLOJURE_CLJ, { icon: SiClojure, iconClass: styles.blue }],
+ [FileExtension.CLOJURE_CLJC, { icon: SiClojure, iconClass: styles.blue }],
+ [FileExtension.CLOJURE_CLJR, { icon: SiClojure, iconClass: styles.blue }],
+ [FileExtension.CLOJURE_CLJS, { icon: SiClojure, iconClass: styles.blue }],
+ [FileExtension.CLOJURE_EDN, { icon: SiClojure, iconClass: styles.blue }],
+ [FileExtension.COFFEE, { icon: SiCoffeescript, iconClass: styles.defaultIcon }],
+ [FileExtension.CPP, { icon: SiCplusplus, iconClass: styles.blue }],
+ [FileExtension.CSHARP, { icon: SiCsharp, iconClass: styles.blue }],
+ [FileExtension.CSS, { icon: FaCss3Alt, iconClass: styles.blue }],
+ [FileExtension.DART, { icon: SiDart, iconClass: styles.blue }],
+ [FileExtension.DEFAULT, { icon: CiWarning, iconClass: styles.red }],
+ [FileExtension.DOCKERFILE, { icon: SiDocker, iconClass: styles.blue }],
+ [FileExtension.DOCKERIGNORE, { icon: SiDocker, iconClass: styles.blue }],
+ [FileExtension.FORTRAN_F, { icon: SiFortran, iconClass: styles.defaultIcon }],
+ [FileExtension.FORTRAN_FOR, { icon: SiFortran, iconClass: styles.defaultIcon }],
+ [FileExtension.FORTRAN_FTN, { icon: SiFortran, iconClass: styles.defaultIcon }],
+ [FileExtension.FSHARP, { icon: SiFsharp, iconClass: styles.blue }],
+ [FileExtension.FSI, { icon: SiFsharp, iconClass: styles.blue }],
+ [FileExtension.FSX, { icon: SiFsharp, iconClass: styles.blue }],
+ [FileExtension.GITIGNORE, { icon: SiGit, iconClass: styles.red }],
+ [FileExtension.GITATTRIBUTES, { icon: SiGit, iconClass: styles.red }],
+ [FileExtension.GO, { icon: SiGo, iconClass: styles.blue }],
+ [FileExtension.GOMOD, { icon: SiGo, iconClass: styles.pink }],
+ [FileExtension.GOSUM, { icon: SiGo, iconClass: styles.pink }],
+ [FileExtension.GROOVY, { icon: SiApachegroovy, iconClass: styles.blue }],
+ [FileExtension.GRAPHQL, { icon: SiGraphql, iconClass: styles.pink }],
+ [FileExtension.HASKELL, { icon: SiHaskell, iconClass: styles.blue }],
+ [FileExtension.HTML, { icon: SiHtml5, iconClass: styles.blue }],
+ [FileExtension.JAVA, { icon: FaJava, iconClass: styles.defaultIcon }],
+ [FileExtension.JAVASCRIPT, { icon: SiJavascript, iconClass: styles.yellow }],
+ [FileExtension.JPG, { icon: SiJpeg, iconClass: styles.yellow }],
+ [FileExtension.JPEG, { icon: SiJpeg, iconClass: styles.yellow }],
+ [FileExtension.JSX, { icon: SiReact, iconClass: styles.yellow }],
+ [FileExtension.JSON, { icon: VscJson, iconClass: styles.defaultIcon }],
+ [FileExtension.JULIA, { icon: SiJulia, iconClass: styles.defaultIcon }],
+ [FileExtension.KOTLIN, { icon: SiKotlin, iconClass: styles.green }],
+ [FileExtension.LOCKFILE, { icon: VscJson, iconClass: styles.defaultIcon }],
+ [FileExtension.LUA, { icon: SiLua, iconClass: styles.blue }],
+ [FileExtension.MARKDOWN, { icon: SiMarkdown, iconClass: styles.blue }],
+ [FileExtension.NCL, { icon: CiSettings, iconClass: styles.defaultIcon }],
+ [FileExtension.NIX, { icon: SiNixos, iconClass: styles.gray }],
+ [FileExtension.NPM, { icon: SiNpm, iconClass: styles.red }],
+ [FileExtension.OCAML, { icon: SiOcaml, iconClass: styles.yellow }],
+ [FileExtension.PHP, { icon: SiPhp, iconClass: styles.cyan }],
+ [FileExtension.PERL, { icon: SiPerl, iconClass: styles.defaultIcon }],
+ [FileExtension.PERL_PM, { icon: SiPerl, iconClass: styles.defaultIcon }],
+ [FileExtension.PNG, { icon: PiFilePngLight, iconClass: styles.defaultIcon }],
+ [FileExtension.POWERSHELL_PS1, { icon: GoTerminal, iconClass: styles.defaultIcon }],
+ [FileExtension.POWERSHELL_PSM1, { icon: GoTerminal, iconClass: styles.defaultIcon }],
+ [FileExtension.PYTHON, { icon: SiPython, iconClass: styles.blue }],
+ [FileExtension.R, { icon: SiR, iconClass: styles.red }],
+ [FileExtension.R_CAP, { icon: SiR, iconClass: styles.red }],
+ [FileExtension.RUBY, { icon: SiRuby, iconClass: styles.red }],
+ [FileExtension.RUST, { icon: SiRust, iconClass: styles.defaultIcon }],
+ [FileExtension.SCALA, { icon: SiScala, iconClass: styles.red }],
+ [FileExtension.SASS, { icon: FaSass, iconClass: styles.pink }],
+ [FileExtension.SQL, { icon: GoDatabase, iconClass: styles.blue }],
+ [FileExtension.SVELTE, { icon: SiSvelte, iconClass: styles.red }],
+ [FileExtension.SVG, { icon: SiSvg, iconClass: styles.blue }],
+ [FileExtension.SWIFT, { icon: SiSwift, iconClass: styles.blue }],
+ [FileExtension.TYPESCRIPT, { icon: SiTypescript, iconClass: styles.blue }],
+ [FileExtension.TSX, { icon: SiReact, iconClass: styles.blue }],
+ [FileExtension.TEXT, { icon: CiTextAlignLeft, iconClass: styles.defaultIcon }],
+ [FileExtension.YAML, { icon: CiSettings, iconClass: styles.defaultIcon }],
+ [FileExtension.YML, { icon: CiSettings, iconClass: styles.defaultIcon }],
+ [FileExtension.ZIG, { icon: SiZig, iconClass: styles.yellow }],
+])
diff --git a/client/web/src/repo/tree/TreePagePanels.tsx b/client/web/src/repo/tree/TreePagePanels.tsx
index 741b9b91c95..e56940bd919 100644
--- a/client/web/src/repo/tree/TreePagePanels.tsx
+++ b/client/web/src/repo/tree/TreePagePanels.tsx
@@ -25,6 +25,8 @@ import type { BlobFileFields, TreeHistoryFields } from '../../graphql-operations
import { fetchBlob } from '../blob/backend'
import { RenderedFile } from '../blob/RenderedFile'
import { CommitMessageWithLinks } from '../commit/CommitMessageWithLinks'
+import { FILE_ICONS, FileExtension } from '../constants'
+import { getFileInfo } from '../utils'
import styles from './TreePagePanels.module.scss'
@@ -176,54 +178,67 @@ export const FilesCard: FC = ({ entries, historyEntries, classNa
- {entries.map(entry => (
-
- |
-
-
- {entry.name}
- {entry.isDirectory && '/'}
-
- |
- {fileHistoryByPath[entry.path] && (
- <>
-
-
- {
+ const fileInfo = getFileInfo(entry.name)
+ const fileIcon = FILE_ICONS.get(fileInfo.extension)
+
+ return (
+
+ |
+
+ {fileInfo.extension !== FileExtension.DEFAULT ? (
+
-
- |
-
-
- |
- >
- )}
-
- ))}
+ ) : (
+
+ )}
+ {entry.name}
+ {entry.isDirectory && '/'}
+
+ |
+ {fileHistoryByPath[entry.path] && (
+ <>
+
+
+
+
+ |
+
+
+ |
+ >
+ )}
+
+ )
+ })}
)
diff --git a/client/web/src/repo/utils.test.ts b/client/web/src/repo/utils.test.ts
index e8b3cc77ae7..1d6c9bac15f 100644
--- a/client/web/src/repo/utils.test.ts
+++ b/client/web/src/repo/utils.test.ts
@@ -1,6 +1,99 @@
import { describe, expect, it } from 'vitest'
-import { getInitialSearchTerm } from './utils'
+import { FileExtension } from './constants'
+import { containsTest, getFileInfo, getInitialSearchTerm } from './utils'
+
+describe('containsTest', () => {
+ const tests: {
+ name: string
+ file: string
+ expected: boolean
+ }[] = [
+ {
+ name: 'returns true if "test_" exists in file name',
+ file: 'test_myfile.go',
+ expected: true,
+ },
+ {
+ name: 'returns true if "_test" exists in file name',
+ file: 'myfile_test.go',
+ expected: true,
+ },
+ {
+ name: 'returns true if "_spec" exists in file name',
+ file: 'myfile_spec.go',
+ expected: true,
+ },
+ {
+ name: 'returns true if "spec_" exists in file name',
+ file: 'spec_myfile.go',
+ expected: true,
+ },
+ {
+ name: 'works with sub-extensions',
+ file: 'myreactcomponent.test.tsx',
+ expected: true,
+ },
+ {
+ name: 'returns false if not a test file',
+ file: 'mytestcomponent.java',
+ expected: false,
+ },
+ ]
+
+ for (const t of tests) {
+ it(t.name, () => {
+ expect(containsTest(t.file)).toBe(t.expected)
+ })
+ }
+})
+
+describe('getFileInfo', () => {
+ const tests: {
+ name: string
+ file: string
+ isDirectory: boolean
+ expectedExtension: FileExtension
+ expectedIsTest: boolean
+ }[] = [
+ {
+ name: 'works with simple file name',
+ file: 'my-file.js',
+ isDirectory: false,
+ expectedExtension: 'js' as FileExtension,
+ expectedIsTest: false,
+ },
+ {
+ name: 'works with complex file name',
+ file: 'my-file.module.scss',
+ isDirectory: false,
+ expectedExtension: 'scss' as FileExtension,
+ expectedIsTest: false,
+ },
+ {
+ name: 'returns isTest as true if file name contains test',
+ file: 'my-file.test.tsx',
+ isDirectory: false,
+ expectedExtension: 'tsx' as FileExtension,
+ expectedIsTest: true,
+ },
+ {
+ name: 'returns isTest as true if file name contains test',
+ file: '.eslintrc',
+ isDirectory: false,
+ expectedExtension: 'default' as FileExtension,
+ expectedIsTest: false,
+ },
+ ]
+
+ for (const t of tests) {
+ it(t.name, () => {
+ const fileInfo = getFileInfo(t.file)
+ expect(fileInfo.extension).toBe(t.expectedExtension)
+ expect(fileInfo.isTest).toBe(t.expectedIsTest)
+ })
+ }
+})
describe('getInitialSearchTerm', () => {
const tests: {
diff --git a/client/web/src/repo/utils.tsx b/client/web/src/repo/utils.tsx
index 3d9eff23687..23861698a20 100644
--- a/client/web/src/repo/utils.tsx
+++ b/client/web/src/repo/utils.tsx
@@ -1,6 +1,6 @@
import { type GitCommitFields, RepositoryType } from '../graphql-operations'
-import { CodeHostType } from './constants'
+import { CodeHostType, FileExtension } from './constants'
export const isPerforceChangelistMappingEnabled = (): boolean =>
window.context.experimentalFeatures.perforceChangelistMapping === 'enabled'
@@ -45,3 +45,42 @@ export const stringToCodeHostType = (codeHostType: string): CodeHostType => {
}
}
}
+
+export interface FileInfo {
+ extension: FileExtension
+ isTest: boolean
+}
+
+export const getFileInfo = (file: string): FileInfo => {
+ const extension = file.split('.').at(-1)
+ const isValidExtension = Object.values(FileExtension).includes(extension as FileExtension)
+
+ if (extension && isValidExtension) {
+ return {
+ extension: extension as FileExtension,
+ isTest: containsTest(file),
+ }
+ }
+
+ return {
+ extension: 'default' as FileExtension,
+ isTest: false,
+ }
+}
+
+export const containsTest = (file: string): boolean => {
+ const f = file.split('.')
+ // To account for other test file path structures
+ // adjust this regular expression.
+ const isTest = /^(test|spec|tests)(\b|_)|(\b|_)(test|spec|tests)$/
+
+ for (const i of f) {
+ if (i === 'test') {
+ return true
+ }
+ if (isTest.test(i)) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/package.json b/package.json
index a3ba00318e2..068f0eaa600 100644
--- a/package.json
+++ b/package.json
@@ -387,6 +387,7 @@
"react-dom": "18.1.0",
"react-focus-lock": "^2.7.1",
"react-grid-layout": "1.3.4",
+ "react-icons": "^4.12.0",
"react-resizable": "^3.0.4",
"react-router-dom": "^6.8.1",
"react-spring": "^9.4.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 07a8abd9e87..837b62c7d9f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -367,6 +367,9 @@ importers:
react-grid-layout:
specifier: 1.3.4
version: 1.3.4(react-dom@18.1.0)(react@18.1.0)
+ react-icons:
+ specifier: ^4.12.0
+ version: 4.12.0(react@18.1.0)
react-resizable:
specifier: ^3.0.4
version: 3.0.4(react-dom@18.1.0)(react@18.1.0)
@@ -21188,6 +21191,14 @@ packages:
react-resizable: 3.0.4(react-dom@18.1.0)(react@18.1.0)
dev: false
+ /react-icons@4.12.0(react@18.1.0):
+ resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==}
+ peerDependencies:
+ react: '*'
+ dependencies:
+ react: 18.1.0
+ dev: false
+
/react-inspector@6.0.2(react@18.1.0):
resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==}
peerDependencies: