diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..8ce49499 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/package.json b/package.json index 2bb2b234..d2972e5e 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,23 @@ "type": "object", "title": "Memo", "properties": { + "memo.backlinksPanel.sortOrder": { + "enum": [ + "path", + "alphabet", + "last-modified", + "last-modified-refs" + ], + "enumDescriptions": [ + "Backlinks are sorted by their paths.", + "Backlinks are sorted by their directionary order.", + "Backlinks are sorted by their last modified time.", + "Backlinks are sorted by their last modified reference's time." + ], + "default": "path", + "scope": "resource", + "description": "Controls the sorting order of backlinks." + }, "memo.backlinksPanel.collapseParentItems": { "default": false, "scope": "resource", @@ -305,6 +322,7 @@ "ts-loader": "^9.4.2", "typescript": "^4.9.5", "vsce": "^2.15.0", + "vscode-uri": "^3.0.8", "wait-for-expect": "^3.0.2", "webpack": "^5.75.0", "webpack-cli": "^5.0.1" diff --git a/src/features/BacklinksTreeDataProvider.ts b/src/features/BacklinksTreeDataProvider.ts index 451ae52c..169a001b 100644 --- a/src/features/BacklinksTreeDataProvider.ts +++ b/src/features/BacklinksTreeDataProvider.ts @@ -87,7 +87,8 @@ export default class BacklinksTreeDataProvider implements vscode.TreeDataProvide ({ location }) => location.uri.fsPath, ); - const pathsSorted = sortPaths(Object.keys(referencesByPath), { shallowFirst: true }); + const sortOrder = getMemoConfigProperty('backlinksPanel.sortOrder', 'path'); + const pathsSorted = await sortPaths(sortOrder)(referencesByPath); if (!pathsSorted.length) { return []; diff --git a/src/features/ReferenceRenameProvider.ts b/src/features/ReferenceRenameProvider.ts index d251ae98..be131a67 100644 --- a/src/features/ReferenceRenameProvider.ts +++ b/src/features/ReferenceRenameProvider.ts @@ -11,7 +11,7 @@ import { containsUnknownExt, findFilesByExts, extractExt, - sortPaths, + crossPathSort, } from '../utils'; const openingBracketsLength = 2; @@ -33,7 +33,7 @@ export default class ReferenceRenameProvider implements RenameProvider { const unknownUris = containsUnknownExt(ref) ? await findFilesByExts([extractExt(ref)]) : []; const augmentedUris = unknownUris.length - ? sortPaths([...cache.getWorkspaceCache().allUris, ...unknownUris], { + ? crossPathSort([...cache.getWorkspaceCache().allUris, ...unknownUris], { pathKey: 'path', shallowFirst: true, }) @@ -69,7 +69,7 @@ export default class ReferenceRenameProvider implements RenameProvider { const unknownUris = containsUnknownExt(ref) ? await findFilesByExts([extractExt(ref)]) : []; const augmentedUris = unknownUris.length - ? sortPaths([...cache.getWorkspaceCache().allUris, ...unknownUris], { + ? crossPathSort([...cache.getWorkspaceCache().allUris, ...unknownUris], { pathKey: 'path', shallowFirst: true, }) diff --git a/src/utils/index.ts b/src/utils/index.ts index 882abb28..b459de74 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,11 +1,11 @@ -import { sort as sortPaths } from 'cross-path-sort'; - import { default as createDailyQuickPick } from './createDailyQuickPick'; import { readClipboard } from './clipboardUtils'; -export { sortPaths, createDailyQuickPick, readClipboard }; +export { sort as crossPathSort } from 'cross-path-sort'; +export { createDailyQuickPick, readClipboard }; export * from './utils'; export * from './externalUtils'; export * from './replaceUtils'; export * from './searchUtils'; +export * from './sortUtils'; diff --git a/src/utils/sortUtils.pure.ts b/src/utils/sortUtils.pure.ts new file mode 100644 index 00000000..5c20398b --- /dev/null +++ b/src/utils/sortUtils.pure.ts @@ -0,0 +1,72 @@ +import type * as vscode from 'vscode'; +import { sort as _sortByPath } from 'cross-path-sort'; + +import { FoundRefT } from '../types'; + +export type GetMTimeOfFile = (uri: vscode.Uri) => Promise; +type PickedFoundRefT = { + location: Pick; +}; + +const sortByPath = async (referencesByPath: Record) => { + // sort keys by path + return _sortByPath(Object.keys(referencesByPath), { shallowFirst: true }); +}; + +const sortByAlphabetical = async (referencesByPath: Record) => { + // sort keys by alphabetical + const pathsSorted = Object.keys(referencesByPath).sort(); + return pathsSorted; +}; + +const getMTimeOfLastModifiedRef = + (getMTimeOfFile: GetMTimeOfFile) => async (refs: PickedFoundRefT[]) => { + // find the last modified ref's mtime in refs + const refsWithModified = await Promise.all(refs.map((ref) => getMTimeOfFile(ref.location.uri))); + return Math.max(...refsWithModified); + }; + +const withModifiedTime = + (getMTimeOfFile: GetMTimeOfFile, file: (path: string) => vscode.Uri) => + (type: SortPathsByModifiedType) => + async ([path, ref]: [string, PickedFoundRefT[]]): Promise> => { + const lastModified = + type === 'last-modified' + ? await getMTimeOfFile(file(path)) + : await getMTimeOfLastModifiedRef(getMTimeOfFile)(ref); + return [lastModified, path] as const; + }; + +const sortByLastModified = + (getMTimeOfFile: GetMTimeOfFile, file: (path: string) => vscode.Uri) => + (type: SortPathsByModifiedType) => + async (referencesByPath: Record): Promise => { + // sort keys by last modified + const refsWithModifiedTime = await Promise.all( + Object.entries(referencesByPath).map(withModifiedTime(getMTimeOfFile, file)(type)), + ); + + const pathsSorted = refsWithModifiedTime.sort(([a], [b]) => b - a).map(([, path]) => path); + return pathsSorted; + }; + +export type SortFunction = ( + referencesByPath: Record, +) => Promise; +export type SortPathsType = 'path' | 'alphabet' | SortPathsByModifiedType; +export type SortPathsByModifiedType = 'last-modified' | 'last-modified-refs'; +export type SortPathsDefaultType = typeof sortPathsDefaultType; +export const sortPathsDefaultType = 'path' satisfies SortPathsType; + +export const _sortPaths = (getMTimeOfFile: GetMTimeOfFile, file: (path: string) => vscode.Uri) => { + const sortMap = { + path: sortByPath, + alphabet: sortByAlphabetical, + 'last-modified': sortByLastModified(getMTimeOfFile, file)('last-modified'), + 'last-modified-refs': sortByLastModified(getMTimeOfFile, file)('last-modified-refs'), + } as const satisfies Record; + + return (order: SortPathsType) => (referencesByPath: Record) => { + return sortMap[order](referencesByPath); + }; +}; diff --git a/src/utils/sortUtils.spec.ts b/src/utils/sortUtils.spec.ts new file mode 100644 index 00000000..4fb9743d --- /dev/null +++ b/src/utils/sortUtils.spec.ts @@ -0,0 +1,138 @@ +import { URI } from 'vscode-uri'; + +import { GetMTimeOfFile, _sortPaths } from './sortUtils.pure'; + +const getMTimeOfFile: GetMTimeOfFile = async (uri: URI) => { + // will inject this prop in uri init + return (uri as any).lastModified; +}; + +const sortPaths = _sortPaths(getMTimeOfFile, URI.file); +const sortByPath = sortPaths('path'); +const sortByAlphabetical = sortPaths('alphabet'); +// const sortByLastModified = sortPaths('last-modified'); sortByLastModified is special (It will call URI.file in function). +const sortByLastModifiedRef = sortPaths('last-modified-refs'); + +const file: (modifiedTimeMap: Record) => (path: string) => URI = + (map) => (path) => { + const uri = URI.file(path); + (uri as any).lastModified = map[path]; + return uri; + }; +const createURIWithLastModified = (lastModified: number): URI => { + const uri = URI.file(`/path/to/${lastModified}`); + (uri as any).lastModified = lastModified; + return uri; +}; + +describe('sortUtils', () => { + describe('sortByPath', () => { + it('should sort references by path in ascending order', async () => { + const referencesByPath = { + 'b/path': [{ location: { uri: createURIWithLastModified(100) } }], + 'a/path': [{ location: { uri: createURIWithLastModified(200) } }], + }; + + const result = await sortByPath(referencesByPath); + expect(result).toEqual(['a/path', 'b/path']); + }); + + it('should handle an empty referencesByPath object', async () => { + const referencesByPath: Record = {}; + const result = await sortByPath(referencesByPath); + expect(result).toEqual([]); + }); + }); + + describe('sortByAlphabetical', () => { + it('should sort references by path in alphabetical order', async () => { + const referencesByPath = { + 'b/path': [{ location: { uri: createURIWithLastModified(100) } }], + 'a/path': [{ location: { uri: createURIWithLastModified(200) } }], + }; + + const result = await sortByAlphabetical(referencesByPath); + expect(result).toEqual(['a/path', 'b/path']); + }); + + it('should handle an empty referencesByPath object', async () => { + const referencesByPath: Record = {}; + const result = await sortByAlphabetical(referencesByPath); + expect(result).toEqual([]); + }); + }); + + describe('sortByLastModified', () => { + it('should sort references by the last modified time', async () => { + const sortPaths = _sortPaths(getMTimeOfFile, file({ path1: 100, path2: 300 })); + const sortByLastModified = sortPaths('last-modified'); + const referencesByPath = { + path1: [], + path2: [], + }; + + const result = await sortByLastModified(referencesByPath); + expect(result).toEqual(['path2', 'path1']); + }); + + it('should handle an empty referencesByPath object', async () => { + const sortPaths = _sortPaths(getMTimeOfFile, file({})); + const sortByLastModified = sortPaths('last-modified'); + + const referencesByPath: Record = {}; + const result = await sortByLastModified(referencesByPath); + expect(result).toEqual([]); + }); + + it('should correctly handle multiple file with the same modification time', async () => { + const referencesByPath = { + path1: [], + path2: [], + }; + + const sortPaths = _sortPaths(getMTimeOfFile, file({ path1: 0, path2: 0 })); + const sortByLastModified = sortPaths('last-modified'); + + // each result is accepted + expect(async () => { + await sortByLastModified(referencesByPath); + }).not.toThrow(); + }); + }); + + describe('sortByLastModifiedRef', () => { + it('should sort references by the last modified time of the reference in ascending order', async () => { + const references = { + path1: [ + { location: { uri: createURIWithLastModified(150) } }, + { location: { uri: createURIWithLastModified(1000) } }, + ], + path2: [ + { location: { uri: createURIWithLastModified(150) } }, + { location: { uri: createURIWithLastModified(2000) } }, + ], + }; + + const result = await sortByLastModifiedRef(references); + expect(result).toEqual(['path2', 'path1']); + }); + + it('should handle an empty array of references', async () => { + const references: Record = {}; + const result = await sortByLastModifiedRef(references); + expect(result).toEqual([]); + }); + + it('should correctly handle multiple references with the same modification time', async () => { + const references = { + path1: [{ location: { uri: createURIWithLastModified(150) } }], + path2: [{ location: { uri: createURIWithLastModified(150) } }], + }; + + // each result is accepted + expect(async () => { + await sortByLastModifiedRef(references); + }).not.toThrow(); + }); + }); +}); diff --git a/src/utils/sortUtils.ts b/src/utils/sortUtils.ts new file mode 100644 index 00000000..eb7e2312 --- /dev/null +++ b/src/utils/sortUtils.ts @@ -0,0 +1,9 @@ +import * as vscode from 'vscode'; + +import { _sortPaths, GetMTimeOfFile } from './sortUtils.pure'; + +const getMTimeOfFile: GetMTimeOfFile = async (uri: vscode.Uri) => { + const stat = await vscode.workspace.fs.stat(uri); + return stat.mtime; +}; +export const sortPaths = _sortPaths(getMTimeOfFile, vscode.Uri.file); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7b99a318..3f29fb4d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import { RefT, FoundRefT, LinkRuleT, ExtractedRefT } from '../types'; import { cache } from '../workspace'; import { isInCodeSpan, isInFencedCodeBlock } from './externalUtils'; +import { SortPathsDefaultType, SortPathsType } from './sortUtils.pure'; const markdownExtRegex = /\.md$/i; @@ -165,6 +166,11 @@ export function getMemoConfigProperty( fallback: 'short', ): 'short' | 'long'; +export function getMemoConfigProperty( + property: 'backlinksPanel.sortOrder', + fallback: SortPathsDefaultType, +): SortPathsType; + export function getMemoConfigProperty( property: 'backlinksPanel.collapseParentItems', fallback: null | boolean, diff --git a/yarn.lock b/yarn.lock index 8667fd62..8453ce8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7690,6 +7690,11 @@ vsce@^2.15.0: yauzl "^2.3.1" yazl "^2.2.2" +vscode-uri@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" + integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== + wait-for-expect@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-3.0.2.tgz#d2f14b2f7b778c9b82144109c8fa89ceaadaa463"