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: