git-truck 0.8.2 → 0.8.6-experimental

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.github/workflows/bump-version.yml +1 -1
  2. package/README.md +17 -13
  3. package/cli.js +2 -0
  4. package/dev.js +4 -2
  5. package/package.json +4 -4
  6. package/server.ts +13 -8
  7. package/src/analyzer/analyze.server.ts +43 -76
  8. package/src/analyzer/analyze.test.ts +30 -30
  9. package/src/analyzer/args.server.ts +20 -6
  10. package/src/analyzer/constants.ts +1 -1
  11. package/src/analyzer/git-caller.server.ts +290 -0
  12. package/src/analyzer/hydrate.server.ts +1 -1
  13. package/src/analyzer/model.ts +13 -2
  14. package/src/analyzer/{util.ts → util.server.ts} +27 -33
  15. package/src/analyzer/util.test.ts +1 -1
  16. package/src/components/AnalyzingIndicator.tsx +55 -0
  17. package/src/components/Animations.ts +14 -0
  18. package/src/components/Chart.tsx +29 -8
  19. package/src/components/Details.tsx +8 -7
  20. package/src/components/GlobalInfo.tsx +19 -8
  21. package/src/components/HiddenFiles.tsx +3 -3
  22. package/src/components/Legend.tsx +1 -1
  23. package/src/components/LegendOther.tsx +42 -42
  24. package/src/components/Main.tsx +1 -1
  25. package/src/components/SearchBar.tsx +1 -6
  26. package/src/components/util.tsx +19 -10
  27. package/src/const.ts +6 -6
  28. package/src/contexts/ClickedContext.ts +17 -17
  29. package/src/contexts/DataContext.ts +12 -12
  30. package/src/contexts/MetricContext.ts +12 -12
  31. package/src/contexts/OptionsContext.ts +51 -51
  32. package/src/contexts/SearchContext.ts +19 -19
  33. package/src/lang-map.d.ts +3 -3
  34. package/src/metrics.ts +3 -2
  35. package/src/root.tsx +44 -1
  36. package/src/routes/{repo.tsx → $repo.tsx} +59 -15
  37. package/src/routes/index.tsx +156 -46
  38. package/build/index.js +0 -6836
  39. package/post-build.js +0 -14
  40. package/public/favicon.ico +0 -0
  41. package/src/analyzer/git-caller.ts +0 -117
  42. package/src/analyzer/index.ts +0 -4
@@ -0,0 +1,290 @@
1
+ import { log } from "./log.server"
2
+ import { getBaseDirFromPath, getDirName, promiseHelper, runProcess } from "./util.server"
3
+ import { resolve, join } from "path"
4
+ import { promises as fs, existsSync } from "fs"
5
+ import { AnalyzerData, AnalyzerDataInterfaceVersion, Repository } from "./model"
6
+
7
+ export enum ANALYZER_CACHE_MISS_REASONS {
8
+ OTHER_REPO = "The cache was not created for this repo",
9
+ NOT_CACHED = "No cache was found",
10
+ BRANCH_HEAD_CHANGED = "Branch head changed",
11
+ DATA_VERSION_MISMATCH = "Outdated cache",
12
+ }
13
+
14
+ export type RawGitObjectType = "blob" | "tree" | "commit" | "tag"
15
+ export type RawGitObject = {
16
+ hash: string
17
+ type: RawGitObjectType
18
+ idk: string
19
+ value: string
20
+ }
21
+
22
+ export class GitCaller {
23
+ private useCache = true
24
+ private repo: string
25
+ private catFileCache: Map<string, string> = new Map()
26
+ private diffNumStatCache: Map<string, string> = new Map()
27
+ private blameCache: Map<string, string> = new Map()
28
+
29
+ private static instance: GitCaller | null = null
30
+
31
+ static initInstance(repo: string) {
32
+ if (!GitCaller.instance || GitCaller.instance.repo !== repo) {
33
+ GitCaller.instance = new GitCaller(repo)
34
+ }
35
+ }
36
+
37
+ static destroyInstance() {
38
+ GitCaller.instance = null
39
+ }
40
+
41
+ static getInstance(): GitCaller {
42
+ if (!GitCaller.instance) {
43
+ throw Error("ObjectDeflator not initialized")
44
+ }
45
+ return GitCaller.instance
46
+ }
47
+
48
+ static async isGitRepo(path: string): Promise<boolean> {
49
+ const gitFolderPath = resolve(path, ".git")
50
+ const hasGitFolder = existsSync(gitFolderPath)
51
+ if (!hasGitFolder) return false
52
+ const [, findBranchHeadError] = await promiseHelper(GitCaller.findBranchHead(path))
53
+ return Boolean(hasGitFolder && !findBranchHeadError)
54
+ }
55
+
56
+ /**
57
+ *
58
+ * @param repo The repo to find the branch head for
59
+ * @param branch The branch to find the head for (default: checkout branch)
60
+ * @returns Promise<[branchHead: string, branchName: string]>
61
+ */
62
+ static async findBranchHead(repo: string, branch?: string): Promise<[string, string]> {
63
+ if (!branch) {
64
+ const [foundBranch, getBranchError] = await promiseHelper(GitCaller.getCurrentBranch(repo))
65
+ if (getBranchError) {
66
+ throw getBranchError
67
+ }
68
+ branch = foundBranch
69
+ }
70
+
71
+ const gitFolder = join(repo, ".git")
72
+ if (!existsSync(gitFolder)) {
73
+ throw Error("No git folder exists at " + gitFolder)
74
+ }
75
+ // Find file containing the branch head
76
+ const branchPath = join(gitFolder, "refs/heads/" + branch)
77
+ const absolutePath = join(process.cwd(), branchPath)
78
+ log.debug("Looking for branch head at " + absolutePath)
79
+
80
+ const branchHead = (await fs.readFile(branchPath, "utf-8")).trim()
81
+ log.debug(`${branch} -> [commit]${branchHead}`)
82
+ if (!branchHead) throw Error("Branch head not found")
83
+
84
+ return [branchHead, branch]
85
+ }
86
+
87
+ static getCachePath(repo: string, branch: string) {
88
+ return resolve(__dirname, "..", ".temp", repo, `${branch}.json`)
89
+ }
90
+
91
+ static async getCurrentBranch(dir: string) {
92
+ const result = (await runProcess(dir, "git", ["rev-parse", "--abbrev-ref", "HEAD"])) as string
93
+ return result.trim()
94
+ }
95
+
96
+ static async scanDirectoryForRepositories(argPath: string): Promise<[Repository | null, Repository[]]> {
97
+ let userRepo: Repository | null = null
98
+ const pathIsRepo = await GitCaller.isGitRepo(argPath)
99
+ const baseDir = resolve(pathIsRepo ? getBaseDirFromPath(argPath) : argPath)
100
+
101
+ const entries = await fs.readdir(baseDir, { withFileTypes: true })
102
+ const dirs = entries.filter((entry) => entry.isDirectory()).map(({ name }) => name)
103
+ const repoOrNull = await Promise.all(
104
+ dirs.map(async (repoDir) => {
105
+ const repoPath = join(baseDir, repoDir)
106
+ const [isRepo] = await promiseHelper(GitCaller.isGitRepo(repoPath))
107
+ if (!isRepo) return null
108
+ const repo: Repository = { name: repoDir, path: repoPath, data: null, reasons: [] }
109
+ try {
110
+ const [findBranchHeadResult, error] = await promiseHelper(GitCaller.findBranchHead(repoPath))
111
+ if (!error) {
112
+ const [branchHead, branch] = findBranchHeadResult
113
+ const [data, reasons] = await GitCaller.retrieveCachedResult({
114
+ repo: repoDir,
115
+ branch,
116
+ branchHead,
117
+ })
118
+ repo.data = data
119
+ repo.reasons = reasons
120
+ }
121
+ } catch (e) {
122
+ return null
123
+ }
124
+ return repo
125
+ })
126
+ )
127
+ const onlyRepos: Repository[] = repoOrNull.filter((currentRepo) => {
128
+ if (currentRepo === null) return false
129
+ if (pathIsRepo && currentRepo.name === getDirName(argPath)) {
130
+ userRepo = currentRepo
131
+ }
132
+ return true
133
+ }) as Repository[]
134
+
135
+ return [userRepo, onlyRepos]
136
+ }
137
+
138
+ static async retrieveCachedResult({
139
+ repo,
140
+ branch,
141
+ branchHead,
142
+ }: {
143
+ repo: string
144
+ branch: string
145
+ branchHead: string
146
+ }): Promise<[AnalyzerData | null, ANALYZER_CACHE_MISS_REASONS[]]> {
147
+ const reasons = []
148
+ const cachedDataPath = GitCaller.getCachePath(repo, branch)
149
+ if (!existsSync(cachedDataPath)) return [null, [ANALYZER_CACHE_MISS_REASONS.NOT_CACHED]]
150
+
151
+ const cachedData = JSON.parse(await fs.readFile(cachedDataPath, "utf8")) as AnalyzerData
152
+
153
+ // Check if the current branchHead matches the hash of the analyzed commit from the cache
154
+ const branchHeadMatches = branchHead === cachedData.commit.hash
155
+ if (!branchHeadMatches) reasons.push(ANALYZER_CACHE_MISS_REASONS.BRANCH_HEAD_CHANGED)
156
+
157
+ // Check if the data uses the most recent analyzer data interface
158
+ const dataVersionMatches = cachedData.interfaceVersion === AnalyzerDataInterfaceVersion
159
+ if (!branchHeadMatches) reasons.push(ANALYZER_CACHE_MISS_REASONS.DATA_VERSION_MISMATCH)
160
+
161
+ // Check if the selected repository has changed
162
+ const repoMatches = repo === cachedData.repo
163
+
164
+ const cacheConditions = {
165
+ branchHeadMatches,
166
+ dataVersionMatches,
167
+ repoMatches,
168
+ }
169
+
170
+ // Only return cached data if every criteria is met
171
+ if (!Object.values(cacheConditions).every(Boolean)) return [null, reasons]
172
+
173
+ return [cachedData, reasons]
174
+ }
175
+
176
+ private constructor(repo: string) {
177
+ this.repo = repo
178
+ }
179
+
180
+ setUseCache(useCache: boolean) {
181
+ this.useCache = useCache
182
+ }
183
+
184
+ private async catFile(hash: string) {
185
+ const result = await runProcess(this.repo, "git", ["cat-file", "-p", hash])
186
+ return result as string
187
+ }
188
+
189
+ async findBranchHead(branch?: string) {
190
+ return await GitCaller.findBranchHead(this.repo, branch)
191
+ }
192
+
193
+ async catFileCached(hash: string): Promise<string> {
194
+ if (!this.useCache) {
195
+ const cachedValue = this.catFileCache.get(hash)
196
+ if (cachedValue) {
197
+ return cachedValue
198
+ }
199
+ }
200
+ const result = await this.catFile(hash)
201
+ this.catFileCache.set(hash, result)
202
+
203
+ return result
204
+ }
205
+
206
+ private async blame(path: string) {
207
+ try {
208
+ const result = await runProcess(this.repo, "git", ["blame", path])
209
+ return result as string
210
+ } catch (e) {
211
+ log.warn(`Could not blame on ${path}. It might have been deleted since last commit.`)
212
+ return ""
213
+ }
214
+ }
215
+
216
+ async blameCached(path: string): Promise<string> {
217
+ if (!this.useCache) {
218
+ const cachedValue = this.blameCache.get(path)
219
+ if (cachedValue) {
220
+ return cachedValue
221
+ }
222
+ }
223
+ const result = await this.blame(path)
224
+ this.blameCache.set(path, result)
225
+
226
+ return result
227
+ }
228
+
229
+ async hasUnstagedChanges() {
230
+ const result = await runProcess(this.repo, "git", ["update-index", "--refresh"])
231
+ return !!result
232
+ }
233
+
234
+ async parseBlame(path: string) {
235
+ const cutString = path.slice(path.indexOf("/") + 1)
236
+ const blame = await this.blameCached(cutString)
237
+ const blameRegex = /\((?<author>.*?)\s+\d{4}-\d{2}-\d{2}/gm
238
+ const matches = blame.match(blameRegex)
239
+ const blameAuthors: Record<string, number> = {}
240
+ matches?.forEach((match) => {
241
+ const author = match
242
+ .slice(1)
243
+ .slice(0, match.length - 11)
244
+ .trim()
245
+ if (author !== "Not Committed Yet") {
246
+ const currentValue = blameAuthors[author] ?? 0
247
+ blameAuthors[author] = currentValue + 1
248
+ }
249
+ })
250
+ return blameAuthors
251
+ }
252
+
253
+ async gitDiffNumStatCached(a: string, b: string) {
254
+ const key = a + b
255
+ if (this.useCache) {
256
+ const cachedValue = this.diffNumStatCache.get(key)
257
+ if (cachedValue) {
258
+ return cachedValue
259
+ }
260
+ }
261
+ const result = await this.gitDiffNumStat(a, b)
262
+ this.diffNumStatCache.set(key, result)
263
+ return result
264
+ }
265
+
266
+ private async gitDiffNumStat(a: string, b: string) {
267
+ const result = await runProcess(this.repo, "git", ["diff", "--numstat", a, b])
268
+ return result as string
269
+ }
270
+
271
+ async getDefaultGitSettingValue(setting: string) {
272
+ const result = await runProcess(this.repo, "git", ["config", setting])
273
+ return result as string
274
+ }
275
+
276
+ async resetGitSetting(settingToReset: string, value: string) {
277
+ if (!value) {
278
+ await runProcess(this.repo, "git", ["config", "--unset", settingToReset])
279
+ log.debug(`Unset ${settingToReset}`)
280
+ } else {
281
+ await runProcess(this.repo, "git", ["config", settingToReset, value])
282
+ log.debug(`Reset ${settingToReset} to ${value}`)
283
+ }
284
+ }
285
+
286
+ async setGitSetting(setting: string, value: string) {
287
+ await runProcess(this.repo, "git", ["config", setting, value])
288
+ log.debug(`Set ${setting} to ${value}`)
289
+ }
290
+ }
@@ -13,7 +13,7 @@ import {
13
13
  PersonWithTime,
14
14
  } from "./model"
15
15
  import { analyzeCommitLight } from "./analyze.server"
16
- import { gitDiffNumStatAnalyzed, lookupFileInTree } from "./util"
16
+ import { gitDiffNumStatAnalyzed, lookupFileInTree } from "./util.server"
17
17
  import { Queue } from "./queue"
18
18
 
19
19
  const renamedFiles = new Map<string, string>()
@@ -1,4 +1,12 @@
1
1
  import { AuthorshipType } from "~/metrics"
2
+ import { ANALYZER_CACHE_MISS_REASONS } from "./git-caller.server"
3
+
4
+ export interface Repository {
5
+ path: string
6
+ name: string
7
+ data: AnalyzerData | null
8
+ reasons: ANALYZER_CACHE_MISS_REASONS[]
9
+ }
2
10
 
3
11
  export type GitObject = GitBlobObject | GitTreeObject
4
12
 
@@ -14,19 +22,21 @@ export interface TruckUserConfig {
14
22
  path?: string
15
23
  unionedAuthors?: string[][]
16
24
  hiddenFiles?: string[]
25
+ invalidateCache?: boolean
17
26
  }
18
27
 
19
28
  export interface TruckConfig {
20
29
  log?: string
21
30
  out?: string
22
- branch: string
31
+ branch?: string
23
32
  path: string
24
33
  unionedAuthors: string[][]
25
34
  hiddenFiles: string[]
35
+ invalidateCache: boolean
26
36
  }
27
37
 
28
38
  // Bump this if AnalyzerData interface chances
29
- export const AnalyzerDataInterfaceVersion = 3
39
+ export const AnalyzerDataInterfaceVersion = 4
30
40
 
31
41
  export interface AnalyzerData {
32
42
  cached: boolean
@@ -40,6 +50,7 @@ export interface AnalyzerData {
40
50
  currentVersion: string
41
51
  latestVersion?: string
42
52
  lastRunEpoch: number
53
+ hasUnstagedChanges: boolean
43
54
  }
44
55
 
45
56
  export interface GitBlobObject extends GitBaseObject {
@@ -5,7 +5,8 @@ import { dirname, resolve, sep } from "path"
5
5
  import { getLogLevel, log, LOG_LEVEL } from "./log.server"
6
6
  import { GitBlobObject, GitTreeObject, AnalyzerData } from "./model"
7
7
  import { performance } from "perf_hooks"
8
- import { GitCaller } from "./git-caller"
8
+ import { GitCaller } from "./git-caller.server"
9
+ import { resolve as resolvePath } from "path"
9
10
 
10
11
  export function last<T>(array: T[]) {
11
12
  return array[array.length - 1]
@@ -15,10 +16,12 @@ export function runProcess(dir: string, command: string, args: string[]) {
15
16
  return new Promise((resolve, reject) => {
16
17
  try {
17
18
  const prcs = spawn(command, args, {
18
- cwd: dir,
19
+ cwd: resolvePath(dir),
19
20
  })
20
21
  const chunks: Uint8Array[] = []
21
- prcs.stderr.once("data", (buf) => reject(buf.toString().trim()))
22
+ const errorHandler = (buf: Error): void => reject(buf.toString().trim())
23
+ prcs.once("error", errorHandler)
24
+ prcs.stderr.once("data", errorHandler)
22
25
  prcs.stdout.on("data", (buf) => chunks.push(buf))
23
26
  prcs.stdout.on("end", () => {
24
27
  resolve(Buffer.concat(chunks).toString().trim())
@@ -94,26 +97,6 @@ export async function lookupFileInTree(tree: GitTreeObject, path: string): Promi
94
97
  return await lookupFileInTree(subtree, dirs.slice(1).join("/"))
95
98
  }
96
99
 
97
- export async function getDefaultGitSettingValue(repoDir: string, setting: string) {
98
- const result = await runProcess(repoDir, "git", ["config", setting])
99
- return result as string
100
- }
101
-
102
- export async function resetGitSetting(repoDir: string, settingToReset: string, value: string) {
103
- if (!value) {
104
- await runProcess(repoDir, "git", ["config", "--unset", settingToReset])
105
- log.debug(`Unset ${settingToReset}`)
106
- } else {
107
- await runProcess(repoDir, "git", ["config", settingToReset, value])
108
- log.debug(`Reset ${settingToReset} to ${value}`)
109
- }
110
- }
111
-
112
- export async function setGitSetting(repoDir: string, setting: string, value: string) {
113
- await runProcess(repoDir, "git", ["config", setting, value])
114
- log.debug(`Set ${setting} to ${value}`)
115
- }
116
-
117
100
  export async function writeRepoToFile(outPath: string, analyzedData: AnalyzerData) {
118
101
  const data = JSON.stringify(analyzedData, null, 2)
119
102
  const dir = dirname(outPath)
@@ -124,13 +107,8 @@ export async function writeRepoToFile(outPath: string, analyzedData: AnalyzerDat
124
107
  return outPath
125
108
  }
126
109
 
127
- export function getRepoName(repoDir: string) {
128
- return resolve(repoDir).split(sep).slice().reverse()[0]
129
- }
130
-
131
- export async function getCurrentBranch(dir: string) {
132
- const result = (await runProcess(dir, "git", ["rev-parse", "--abbrev-ref", "HEAD"])) as string
133
- return result.trim()
110
+ export function getDirName(dir: string) {
111
+ return resolve(dir).split(sep).slice().reverse()[0]
134
112
  }
135
113
 
136
114
  export const formatMs = (ms: number) => {
@@ -167,7 +145,7 @@ export async function describeAsyncJob<T>(
167
145
  beforeMsg: string,
168
146
  afterMsg: string,
169
147
  errorMsg: string
170
- ) {
148
+ ): Promise<[T, null] | [null, Error]> {
171
149
  spinner = createTruckSpinner()
172
150
  const success = (text: string, final = false) => {
173
151
  if (getLogLevel() === LOG_LEVEL.SILENT) return
@@ -194,10 +172,26 @@ export async function describeAsyncJob<T>(
194
172
  const stopTime = performance.now()
195
173
  const suffix = `[${formatMs(stopTime - startTime)}]`
196
174
  success(`${afterMsg} ${suffix}`, true)
197
- return result
175
+ return [result, null]
198
176
  } catch (e) {
199
177
  error(errorMsg)
200
178
  log.error(e as Error)
201
- process.exit(1)
179
+ return [null, e as Error]
180
+ }
181
+ }
182
+
183
+ export const getBaseDirFromPath = (path: string) => resolve(path, "..")
184
+ export const getSiblingRepository = (path: string, repo: string) => resolve(getBaseDirFromPath(path), repo)
185
+
186
+ /**
187
+ * This functions handles try / catch for you, so your code stays flat.
188
+ * @param promise An async function
189
+ * @returns A tuple of the result and an error. If there is no error, the error will be null.
190
+ */
191
+ export async function promiseHelper<T>(promise: Promise<T>): Promise<[null, Error] | [T, null]> {
192
+ try {
193
+ return [await promise, null]
194
+ } catch (e) {
195
+ return [null, e as Error]
202
196
  }
203
197
  }
@@ -1,4 +1,4 @@
1
- import { last } from "./util"
1
+ import { last } from "./util.server"
2
2
 
3
3
  describe("last", () => {
4
4
  it("gets the last element of the array", () => {
@@ -0,0 +1,55 @@
1
+ import styled from "styled-components"
2
+
3
+ const LoadingPane = styled.div`
4
+ padding: 0.5em 2em;
5
+ display: grid;
6
+ place-items: center;
7
+ border-radius: 5px;
8
+
9
+ /* hide_initially animation */
10
+ opacity: 0;
11
+ animation: hide_initially 0s linear forwards;
12
+ animation-delay: 1s;
13
+ `
14
+
15
+ const FullViewbox = styled.div`
16
+ display: grid;
17
+ place-items: center;
18
+ height: 100vh;
19
+ width: 100vw;
20
+ `
21
+
22
+ const StyledPath = styled.path`
23
+ fill: none;
24
+ stroke: #4580ff;
25
+ animation: dash 2s ease-in-out alternate infinite;
26
+ `
27
+
28
+ const LoadingText = styled.div`
29
+ text-align: center;
30
+ grid-area: 1/2;
31
+ `
32
+
33
+ const StyledSVG = styled.svg`
34
+ grid-area: 1/2;
35
+ `
36
+
37
+ export function AnalyzingIndicator() {
38
+ const width = 20
39
+ const height = 20
40
+ const length = width + height
41
+
42
+ const path = `M0,0 m-${width * 0.5},-${height * 0.5} l${width},0 l0,${height} l-${width},0 l0,-${height} Z`
43
+ const viewBox = `-${height * 0.5} -${width * 0.5} ${height} ${width}`
44
+
45
+ return (
46
+ <FullViewbox>
47
+ <LoadingPane>
48
+ <LoadingText>Analyzing...</LoadingText>
49
+ <StyledSVG height="160px" width="160px" viewBox={viewBox}>
50
+ <StyledPath strokeDasharray={length * 0.5} strokeDashoffset={length * 2} d={path}></StyledPath>
51
+ </StyledSVG>
52
+ </LoadingPane>
53
+ </FullViewbox>
54
+ )
55
+ }
@@ -0,0 +1,14 @@
1
+ import { keyframes } from "styled-components";
2
+
3
+ export const pulseAnimation = keyframes`
4
+ 0% { stroke-width: 1px; stroke-opacity: 0%; }
5
+ 50% { stroke-opacity: 50% }
6
+ 99% { stroke-width: 5px; stroke-opacity: 0%; }
7
+ 100% { stroke-width: 1px; stroke-opacity: 0%; }
8
+ `
9
+
10
+ export const blinkAnimation = keyframes`
11
+ 0% { opacity: 100% }
12
+ 50% { opacity: 50% }
13
+ 100% { opacity: 100% }
14
+ `
@@ -23,6 +23,7 @@ import { useData } from "../contexts/DataContext"
23
23
  import { useMetrics } from "../contexts/MetricContext"
24
24
  import { ChartType, useOptions } from "../contexts/OptionsContext"
25
25
  import { usePath } from "../contexts/PathContext"
26
+ import { blinkAnimation, pulseAnimation } from "./Animations"
26
27
  import { Tooltip } from "./Tooltip"
27
28
 
28
29
  type CircleOrRectHiearchyNode = HierarchyCircularNode<HydratedGitObject> | HierarchyRectangularNode<HydratedGitObject>
@@ -44,7 +45,7 @@ export function Chart(props: ChartProps) {
44
45
  const data = useData()
45
46
  const { chartType } = useOptions()
46
47
  const { path } = usePath()
47
- const { setClickedObject } = useClickedObject()
48
+ const { clickedObject, setClickedObject } = useClickedObject()
48
49
  const { setPath } = usePath()
49
50
 
50
51
  const nodes = useMemo(() => {
@@ -68,7 +69,7 @@ export function Chart(props: ChartProps) {
68
69
  onMouseOver: () => setHoveredBlob(null),
69
70
  onMouseOut: () => setHoveredBlob(null),
70
71
  }
71
-
72
+
72
73
  return (
73
74
  <>
74
75
  <SVG
@@ -78,9 +79,9 @@ export function Chart(props: ChartProps) {
78
79
  >
79
80
  {nodes?.descendants().map((d, i) => {
80
81
  return (
81
- <g key={`${chartType}${d.data.path}`} {...createGroupHandlers(d)}>
82
+ <G blink={clickedObject?.path === d.data.path} key={`${chartType}${d.data.path}`} {...createGroupHandlers(d)}>
82
83
  <Node isRoot={i === 0} d={d} />
83
- </g>
84
+ </G>
84
85
  )
85
86
  })}
86
87
  </SVG>
@@ -89,6 +90,12 @@ export function Chart(props: ChartProps) {
89
90
  )
90
91
  }
91
92
 
93
+ const G = styled.g<{ blink: boolean }>`
94
+ animation-name: ${ props => props.blink ? blinkAnimation : "none" };
95
+ animation-duration: 2s;
96
+ animation-iteration-count: infinite;
97
+ `
98
+
92
99
  const Node = memo(function Node({ d, isRoot }: { d: CircleOrRectHiearchyNode; isRoot: boolean }) {
93
100
  const { chartType } = useOptions()
94
101
  let showLabel = isTree(d.data)
@@ -160,13 +167,20 @@ function Circle({ d, isSearchMatch }: { d: HierarchyCircularNode<HydratedGitObje
160
167
  cy: d.y,
161
168
  r: Math.max(d.r - 1, 0),
162
169
  stroke: isSearchMatch ? searchMatchColor : "transparent",
163
- strokeWidth: isSearchMatch ? "4px" : "1px",
170
+ strokeWidth: "1px",
164
171
  fill: metricsData[authorshipType].get(metricType)?.colormap.get(d.data.path) ?? "grey",
165
172
  })
166
173
 
167
- return <animated.circle {...props} className={d.data.type} />
174
+ return <CircleSVG pulse={isSearchMatch} {...props} className={d.data.type} />
168
175
  }
169
176
 
177
+ const CircleSVG = styled(animated.circle)<{ pulse: boolean }>`
178
+ animation-name: ${ props => props.pulse ? pulseAnimation : "none" };
179
+ animation-duration: 1.5s;
180
+ animation-iteration-count: infinite;
181
+ animation-timing-function: ease-in-out;
182
+ `
183
+
170
184
  function Rect({ d, isSearchMatch }: { d: HierarchyRectangularNode<HydratedGitObject>; isSearchMatch: boolean }) {
171
185
  const metricsData = useMetrics()
172
186
  const { metricType, authorshipType } = useOptions()
@@ -178,7 +192,7 @@ function Rect({ d, isSearchMatch }: { d: HierarchyRectangularNode<HydratedGitObj
178
192
  height: d.y1 - d.y0,
179
193
 
180
194
  stroke: isSearchMatch ? searchMatchColor : "transparent",
181
- strokeWidth: isSearchMatch ? "4px" : "1px",
195
+ strokeWidth: "1px",
182
196
 
183
197
  fill:
184
198
  d.data.type === "blob"
@@ -186,9 +200,16 @@ function Rect({ d, isSearchMatch }: { d: HierarchyRectangularNode<HydratedGitObj
186
200
  : "transparent",
187
201
  })
188
202
 
189
- return <animated.rect {...props} className={d.data.type} />
203
+ return <RectSVG pulse={isSearchMatch} {...props} className={d.data.type} />
190
204
  }
191
205
 
206
+ const RectSVG = styled(animated.rect)<{ pulse: boolean }>`
207
+ animation-name: ${ props => props.pulse ? pulseAnimation : "none" };
208
+ animation-duration: 1.5s;
209
+ animation-iteration-count: infinite;
210
+ animation-timing-function: ease-in-out;
211
+ `
212
+
192
213
  function CircleText({
193
214
  d,
194
215
  displayText,
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react"
2
- import { Form, useTransition } from "remix"
2
+ import { Form, useNavigate, useTransition } from "remix"
3
3
  import styled from "styled-components"
4
4
  import { HydratedGitBlobObject, HydratedGitObject, HydratedGitTreeObject } from "~/analyzer/model"
5
5
  import { calculateAuthorshipForSubTree } from "~/authorUnionUtil"
@@ -7,7 +7,7 @@ import { AuthorDistFragment } from "~/components/AuthorDistFragment"
7
7
  import { AuthorDistOther } from "~/components/AuthorDistOther"
8
8
  import { Spacer } from "~/components/Spacer"
9
9
  import { ExpandDown } from "~/components/Toggle"
10
- import { Box, BoxTitle, DetailsKey, DetailsValue, InlineCode, NavigateBackButton, TextButton } from "~/components/util"
10
+ import { Box, BoxTitle, DetailsKey, DetailsValue, Code, NavigateBackButton, TextButton } from "~/components/util"
11
11
  import { useClickedObject } from "~/contexts/ClickedContext"
12
12
  import { useData } from "~/contexts/DataContext"
13
13
  import { useOptions } from "~/contexts/OptionsContext"
@@ -29,6 +29,7 @@ export function Details() {
29
29
  const { setPath, path } = usePath()
30
30
  const data = useData()
31
31
  const isProcessingHideRef = useRef(false)
32
+ const navigate = useNavigate()
32
33
 
33
34
  useEffect(() => {
34
35
  if (isProcessingHideRef.current) {
@@ -74,7 +75,7 @@ export function Details() {
74
75
  <Spacer lg />
75
76
  {isBlob ? (
76
77
  <>
77
- <Form method="post" action="/repo">
78
+ <Form method="post" action=".">
78
79
  <input type="hidden" name="ignore" value={clickedObject.path} />
79
80
  <TextButton
80
81
  type="submit"
@@ -89,7 +90,7 @@ export function Details() {
89
90
  {clickedObject.name.includes(".") ? (
90
91
  <>
91
92
  <Spacer />
92
- <Form method="post" action="/repo">
93
+ <Form method="post" action=".">
93
94
  <input type="hidden" name="ignore" value={`*.${extension}`} />
94
95
  <TextButton
95
96
  type="submit"
@@ -98,19 +99,19 @@ export function Details() {
98
99
  isProcessingHideRef.current = true
99
100
  }}
100
101
  >
101
- Hide all <InlineCode>.{extension}</InlineCode> files
102
+ Hide all <Code inline>.{extension}</Code> files
102
103
  </TextButton>
103
104
  </Form>
104
105
  </>
105
106
  ) : null}
106
107
  <Spacer />
107
- <Form method="post" action="/repo">
108
+ <Form method="post" action=".">
108
109
  <input type="hidden" name="open" value={clickedObject.path} />
109
110
  <TextButton disabled={state !== "idle"}>Open file</TextButton>
110
111
  </Form>
111
112
  </>
112
113
  ) : (
113
- <Form method="post" action="/repo">
114
+ <Form method="post" action=".">
114
115
  <input type="hidden" name="ignore" value={clickedObject.path} />
115
116
  <TextButton
116
117
  type="submit"