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.
- package/.github/workflows/bump-version.yml +1 -1
- package/README.md +17 -13
- package/cli.js +2 -0
- package/dev.js +4 -2
- package/package.json +4 -4
- package/server.ts +13 -8
- package/src/analyzer/analyze.server.ts +43 -76
- package/src/analyzer/analyze.test.ts +30 -30
- package/src/analyzer/args.server.ts +20 -6
- package/src/analyzer/constants.ts +1 -1
- package/src/analyzer/git-caller.server.ts +290 -0
- package/src/analyzer/hydrate.server.ts +1 -1
- package/src/analyzer/model.ts +13 -2
- package/src/analyzer/{util.ts → util.server.ts} +27 -33
- package/src/analyzer/util.test.ts +1 -1
- package/src/components/AnalyzingIndicator.tsx +55 -0
- package/src/components/Animations.ts +14 -0
- package/src/components/Chart.tsx +29 -8
- package/src/components/Details.tsx +8 -7
- package/src/components/GlobalInfo.tsx +19 -8
- package/src/components/HiddenFiles.tsx +3 -3
- package/src/components/Legend.tsx +1 -1
- package/src/components/LegendOther.tsx +42 -42
- package/src/components/Main.tsx +1 -1
- package/src/components/SearchBar.tsx +1 -6
- package/src/components/util.tsx +19 -10
- package/src/const.ts +6 -6
- package/src/contexts/ClickedContext.ts +17 -17
- package/src/contexts/DataContext.ts +12 -12
- package/src/contexts/MetricContext.ts +12 -12
- package/src/contexts/OptionsContext.ts +51 -51
- package/src/contexts/SearchContext.ts +19 -19
- package/src/lang-map.d.ts +3 -3
- package/src/metrics.ts +3 -2
- package/src/root.tsx +44 -1
- package/src/routes/{repo.tsx → $repo.tsx} +59 -15
- package/src/routes/index.tsx +156 -46
- package/build/index.js +0 -6836
- package/post-build.js +0 -14
- package/public/favicon.ico +0 -0
- package/src/analyzer/git-caller.ts +0 -117
- 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>()
|
package/src/analyzer/model.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
128
|
-
return resolve(
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
`
|
package/src/components/Chart.tsx
CHANGED
|
@@ -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
|
-
<
|
|
82
|
+
<G blink={clickedObject?.path === d.data.path} key={`${chartType}${d.data.path}`} {...createGroupHandlers(d)}>
|
|
82
83
|
<Node isRoot={i === 0} d={d} />
|
|
83
|
-
</
|
|
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:
|
|
170
|
+
strokeWidth: "1px",
|
|
164
171
|
fill: metricsData[authorshipType].get(metricType)?.colormap.get(d.data.path) ?? "grey",
|
|
165
172
|
})
|
|
166
173
|
|
|
167
|
-
return <
|
|
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:
|
|
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 <
|
|
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,
|
|
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="
|
|
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="
|
|
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 <
|
|
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="
|
|
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="
|
|
114
|
+
<Form method="post" action=".">
|
|
114
115
|
<input type="hidden" name="ignore" value={clickedObject.path} />
|
|
115
116
|
<TextButton
|
|
116
117
|
type="submit"
|