Code Navigation: different icons for different file types (#58421)

Each item in the tree view now has an icon matching the file extension/language.
This commit is contained in:
Jason Hawk Harris 2023-11-28 13:58:09 -06:00 committed by GitHub
parent 882d5674a8
commit 70c9eb357e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 489 additions and 55 deletions

View File

@ -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",

View File

@ -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);
}

View File

@ -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 <ErrorAlert {...props} className={classNames(props.className, 'm-0')} variant="note" error={error} />
@ -470,12 +474,25 @@ function renderNode({
}
}}
>
<Icon
svgPath={isBranch ? (isExpanded ? mdiFolderOpenOutline : mdiFolderOutline) : mdiFileDocumentOutline}
className={classNames('mr-1', styles.icon)}
aria-hidden={true}
/>
{name}
<div className={styles.iconContainer}>
{fileInfo.extension !== FileExtension.DEFAULT ? (
<Icon
as={fileIcon?.icon}
className={classNames('mr-1', styles.icon, fileIcon?.iconClass)}
aria-hidden={true}
/>
) : (
<Icon
svgPath={
isBranch ? (isExpanded ? mdiFolderOpenOutline : mdiFolderOutline) : mdiFileDocumentOutline
}
className={classNames('mr-1', styles.icon)}
aria-hidden={true}
/>
)}
{fileInfo.isTest && <div className={classNames(styles.testIndicator)} />}
{name}
</div>
</Link>
)
}

View File

@ -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<FileExtension, IconInfo> = 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 }],
])

View File

@ -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<FilePanelProps> = ({ entries, historyEntries, classNa
</CardHeader>
</thead>
<tbody>
{entries.map(entry => (
<tr key={entry.name}>
<td className={styles.fileName}>
<LinkOrSpan
to={entry.url}
className={classNames(
'test-page-file-decorable',
entry.isDirectory && 'font-weight-bold',
`test-tree-entry-${entry.isDirectory ? 'directory' : 'file'}`
)}
title={entry.path}
data-testid="tree-entry"
>
<Icon
className="mr-1"
svgPath={entry.isDirectory ? mdiFolderOutline : mdiFileDocumentOutline}
aria-hidden={true}
/>
{entry.name}
{entry.isDirectory && '/'}
</LinkOrSpan>
</td>
{fileHistoryByPath[entry.path] && (
<>
<td className={styles.commitMessage}>
<span
title={fileHistoryByPath[entry.path].subject}
data-testid="git-commit-message-with-links"
>
<CommitMessageWithLinks
to={fileHistoryByPath[entry.path].canonicalURL}
message={fileHistoryByPath[entry.path].subject}
className="text-muted"
externalURLs={fileHistoryByPath[entry.path].externalURLs}
{entries.map(entry => {
const fileInfo = getFileInfo(entry.name)
const fileIcon = FILE_ICONS.get(fileInfo.extension)
return (
<tr key={entry.name}>
<td className={styles.fileName}>
<LinkOrSpan
to={entry.url}
className={classNames(
'test-page-file-decorable',
entry.isDirectory && 'font-weight-bold',
`test-tree-entry-${entry.isDirectory ? 'directory' : 'file'}`
)}
title={entry.path}
data-testid="tree-entry"
>
{fileInfo.extension !== FileExtension.DEFAULT ? (
<Icon
as={fileIcon?.icon}
className={classNames('mr-1', fileIcon?.iconClass)}
aria-hidden={true}
/>
</span>
</td>
<td className={classNames(styles.commitDate, 'text-muted')}>
<Timestamp
noAbout={true}
noAgo={true}
date={getCommitDate(fileHistoryByPath[entry.path])}
/>
</td>
</>
)}
</tr>
))}
) : (
<Icon
svgPath={entry.isDirectory ? mdiFolderOutline : mdiFileDocumentOutline}
className={classNames('mr-1')}
aria-hidden={true}
/>
)}
{entry.name}
{entry.isDirectory && '/'}
</LinkOrSpan>
</td>
{fileHistoryByPath[entry.path] && (
<>
<td className={styles.commitMessage}>
<span
title={fileHistoryByPath[entry.path].subject}
data-testid="git-commit-message-with-links"
>
<CommitMessageWithLinks
to={fileHistoryByPath[entry.path].canonicalURL}
message={fileHistoryByPath[entry.path].subject}
className="text-muted"
externalURLs={fileHistoryByPath[entry.path].externalURLs}
/>
</span>
</td>
<td className={classNames(styles.commitDate, 'text-muted')}>
<Timestamp
noAbout={true}
noAgo={true}
date={getCommitDate(fileHistoryByPath[entry.path])}
/>
</td>
</>
)}
</tr>
)
})}
</tbody>
</Card>
)

View File

@ -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: {

View File

@ -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
}

View File

@ -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",

View File

@ -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: