Initial commit

This commit is contained in:
shumadrid 2025-03-12 00:52:52 +01:00
commit 0994229493
83 changed files with 19049 additions and 0 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
tab_width = 2
trim_trailing_whitespace = true

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

15
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

71
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,71 @@
name: Bug report
description: Report a bug and help improve the plugin
title: '[BUG] Short description of the bug'
labels: bug
assignees: shumadrid
body:
- type: markdown
attributes:
value: |
## Bug report
- type: textarea
attributes:
label: Description
description: A clear and concise description of the bug. Include any relevant details.
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: Provide a step-by-step description.
value: |
1. Go to '...'
2. Click on '...'
3. Notice that '...'
...
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: What actually happened? Include error messages if available.
validations:
required: true
- type: textarea
attributes:
label: Environment Information
description: Environment Information
value: |
- **Plugin Version**: [e.g., 1.0.0]
- **Obsidian Version**: [e.g., v1.3.2]
- **Operating System**: [e.g., Windows 10]
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: Required for bug reproduction
value: |
- Please attach a video showing the bug. It is not mandatory, but might be very helpful to speed up the bug fix
- Please attach a sample vault where the bug can be reproduced. It is not mandatory, but might be very helpful to speed up the bug fix
validations:
required: true
- type: checkboxes
attributes:
label: Confirmations
description: Ensure the following conditions are met
options:
- label: I attached a video showing the bug, or it is not necessary
required: true
- label: I attached a sample vault where the bug can be reproduced, or it is not necessary
required: true
- label: I have tested the bug with the latest version of the plugin
required: true
- label: I have checked GitHub for existing bugs
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: false

View file

@ -0,0 +1,61 @@
name: Feature request
description: Request a feature and help improve the plugin
title: '[FR] Short description of the feature'
labels: enhancement
assignees: shumadrid
body:
- type: markdown
attributes:
value: |
## Feature Request
- type: textarea
attributes:
label: Description
description: A clear and concise description of the feature request. Include any relevant details.
validations:
required: true
- type: textarea
attributes:
label: Details
description: Provide a step-by-step description.
value: |
1. Go to '...'
2. Click on '...'
3. Notice that '...'
...
validations:
required: true
- type: textarea
attributes:
label: Desired Behavior
description: What do you want to happen?
validations:
required: true
- type: textarea
attributes:
label: Current Behavior
description: What actually happens?
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: Required for feature investigation
value: |
- Please attach a video showing the current behavior. It is not mandatory, but might be very helpful to speed up the feature implementation
- Please attach a sample vault where the desired Feature Request could be applied. It is not mandatory, but might be very helpful to speed up the feature implementation
validations:
required: true
- type: checkboxes
attributes:
label: Confirmations
description: Ensure the following conditions are met
options:
- label: I attached a video showing the current behavior, or it is not necessary
required: true
- label: I attached a sample vault where the desired Feature Request could be applied, or it is not necessary
required: true
- label: I have tested the absence of the requested feature with the latest version of the plugin
required: true
- label: I have checked GitHub for existing Feature Requests
required: true

BIN
.github/file-changelog-view.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
.github/vault-changelog-view.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# Obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.env
/tsconfig.tsbuildinfo
dist

39
.prettierignore Normal file
View file

@ -0,0 +1,39 @@
# Build output
dist/
build/
# Dependencies
node_modules/
# Cache directories
.cache/
.svelte-kit/
# Environment files
.env
.env.*
# Editor directories
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Temporary files
*.tmp
.temp/
tmp/
# Coverage directories
coverage/
# TypeScript
*.tsbuildinfo

7
.prettierrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 shumadrid
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

157
README.md Normal file
View file

@ -0,0 +1,157 @@
# Git Changelog
A new plugin that utilizes Git commit history to display dynamic changelogs in your sidebar.
## Installation
This plugin is in early beta. The [beta releases](obsidian://brat?plugin=https://github.com/shumadrid/obsidian-git-changelog) can be installed via [BRAT](https://obsidian.md/plugins?id=obsidian42-brat).
It is NOT yet available in [the official Community Plugins repository](https://obsidian.md/plugins).
> [!IMPORTANT]
> Requires the [Git plugin](https://github.com/Vinzent03/obsidian-git) to be installed. Installing it and maintaining a Git repository can be beneficial, even if you rely on other sync or backup services.
## Features
### Vault Changelog View
- Detects renamed and moved files separately.
- Shows the total count of lines added and deleted.
- Displays counts of files added, modified, deleted, and moved/renamed (total or text and non-text separately).
- Lists per file lines added and deleted, along with file statuses (**M**odified, **A**dded, **R**enamed/moved, or **D**eleted).
- **Exclude Files and Folders**:
Usually you specify files and folders you want to exclude from your Git repository in the `.gitignore` file.
But this setting is useful if you want to keep some files in your repository but exclude them from appearing in the vault changelog.
Common examples for this: `.trash` and `.obsidian` folders, `.canvas` files, or syncing service conflict files that can clutter the changelog stats.
![Vault Changelog View](.github/vault-changelog-view.webp)
### File Changelog View
- Shows the count of added and deleted lines for all previous versions of the active note.
- Only Markdown files are currently supported.
![File Changelog View](.github/file-changelog-view.webp)
> [!WARNING]
> Expect poor view performance on scrolling. If scrolling the changelogs doesn't work, restart Obsidian. This will be fixed soon.
### Changelog Settings
- Set a custom day start time (if you're a night owl and want to track your actual days).
- Set a custom timezone to base the intervals on or detect it automatically.
- Choose to view hourly, daily, weekly, or monthly intervals.
### Live Stats in the Status Bar for the Current Note
- Set any interval for the status bar stats. Ensure this interval isn't more frequent than the [Git plugin's](https://github.com/Vinzent03/obsidian-git) auto-commit interval to maintain accuracy.
### Integration with Git Plugin's Diff View
- Click on any file version in the changelog views to open the corresponding diff view showing the changes.
## Data Loss Monitoring
This plugin can also serve as a tool for detecting data loss: if the displayed changelog stats look incorrect, **unintended** vault changes might have occurred.
### Common Causes of Data Loss
- Sync conflicts
- Faulty plugins
- Device malfunction
- Accidental text overwrites and other user errors
- **AI Tools**: They have a tendency to overwrite or delete random content.
Using this plugin you can experiment with AI with less anxiety, knowing that you can notice most data loss.
### How to Use
This plugin was not designed to accumulate all changes made inside an interval.
For example, if you add a block of text, delete it, re-add it, then delete it again in the same day, the changelog will show zero modifications instead of a bunch of additions and deletions.
This is because this plugin only compares neighboring versions (intervals) directly, and if a block of text didn't exist in the previous interval and is also absent in the latest interval, zero changes have occurred.
Consequently, if you add text that gets lost within the same interval, you can detect this data loss **not** by seeing a high 'lines deleted' count, but by noticing a lack of expected additions.
If some lost data was never committed, it won't be recoverable by Git. So it's recommended to configure a frequent (< 5 minutes) auto-commit interval (no need to manually stage and commit files).
But if some version of a file wasn't committed, you can still rely on the [File Recovery](https://help.obsidian.md/plugins/file-recovery) plugin, or the [Version history](https://help.obsidian.md/Obsidian+Sync/Version+history) feature to recover it.
## Limitations
- No meaningful way of tracking changes to canvas files.
- No mobile support yet.
- File changelog doesn't clear if opening a diff view but keeps showing stats for the previous note. This is intentional for quickly switching between versions but may result in showing stats for a closed note. This will be fixed in the future.
- Only status bar counts update live; other views refresh on each commit because of performance reasons.
- It doesn't show stats for the initial version of a file/vault (will be fixed in a future update).
- Assumes linear Git commit history—unexpected branching or merging may produce unexpected results.
- Git does not track changes inside other nested Git repositories—you should use proper Git submodules instead if you want to track changes for multiple repositories inside your vault.
- By Git design, files/folders specified in `.gitignore` aren't watched for changes.
- Git decides if a file is binary (non-text) or a text file by analyzing the file contents rather than looking at the file extension. If you rename a text file to have a `.png` extension, Git will still count its lines and treat it as a text file.
- If some data loss is only a few words/lines in a heavily edited file, you probably won't notice it. Even though those few lines could have been important.
## Roadmap
- [ ] Partial mobile support.
- [ ] Better view performance.
- [ ] Stable and consistent styling.
- [ ] Dedicated moved lines/words detection.
- [ ] File changelog support for all text files.
- [ ] Add a word counting option (currently defaults to line counting).
- [ ] Syncthing integration (better conflict file handling).
- [ ] Visual representation of repo history.
- [ ] Code cleanup and refactoring.
- [ ] Improve README.
- [ ] Folder stats in the vault changelog view.
- [ ] Compare the current state of the vault (repo) to any point in history.
- [ ] Extensive per-file type stats.
- [ ] Optimize performance.
- [ ] Integrate the status bar with the [Git plugin's](https://github.com/Vinzent03/obsidian-git) diff views.
- [ ] Notify if the amount of changes between neighboring commits exceeds a threshold.
- [ ] File & folder stats inside the File explorer view.
## Alternatives
If you don't want to depend on Git, check out the [Obsidian Vault Changelog](https://github.com/philoserf/obsidian-vault-changelog) plugin.
## FAQ
### Will this plugin alter my Git repository/config?
- No. This plugin is intended to be read-only and does not alter any Git configurations.
However, it's in early beta, so bugs are possible.
### I have been using Obsidian for a long time but don't have a Git repo set up. Can I still use this plugin to see my vault history?
- No. 😔
## Debugging
By default, debug messages for this plugin are hidden. They aren't intended for the user.
To show them, enable `Verbose` mode in the console and run the following command:
```js
window.DEBUG.enable('git-changelog');
```
For more details, refer to [this guide](https://github.com/mnaoumov/obsidian-dev-utils?tab=readme-ov-file#debugging).
## Contributing
Feel free to submit a PR or a feature request. I'd especially appreciate help with styling and restructuring the code.
This plugin is in early beta, so please do report all bugs that you find.
For easier development, [define](https://github.com/mnaoumov/generator-obsidian-plugin?tab=readme-ov-file#build-development-version) a `OBSIDIAN_CONFIG_DIR` variable and run `npm run dev`.
## Credits
- [FlorianWoelki](https://github.com/FlorianWoelki/obsidian-iconize) for settings code inspiration.
- Plugin template was generated with [generator-obsidian-plugin](https://github.com/mnaoumov/generator-obsidian-plugin), a modern Obsidian plugin template.
- A lot of functions are adapted from the [Git plugin](https://github.com/Vinzent03/obsidian-git).

102
cspell.json Normal file
View file

@ -0,0 +1,102 @@
{
"version": "0.2",
"ignorePaths": ["dist", "node_modules", "tsconfig.tsbuildinfo"],
"dictionaryDefinitions": [],
"dictionaries": [],
"words": [
"binaryop",
"callouts",
"currencyformat",
"cyrb",
"Datarow",
"Dataview",
"Dataviewjs",
"dataviews",
"dcts",
"Debouncer",
"dirents",
"dmts",
"dprint",
"durationformat",
"econtains",
"eleted",
"elink",
"enamed",
"errorbox",
"etags",
"fdefault",
"fileoverview",
"Florian",
"Frontmatter",
"gerhobbelt",
"hotreload",
"Ingrouped",
"inlinks",
"instanceof",
"jinder",
"Jsons",
"Keymap",
"ldefault",
"lezer",
"LICENCE",
"Linkpath",
"Linktext",
"localforage",
"localtime",
"luxon",
"minby",
"nameof",
"nonnull",
"nosourcemap",
"npmjs",
"numstat",
"odified",
"offref",
"Olehov",
"outdir",
"outfile",
"outlinks",
"padleft",
"padright",
"Pagerow",
"parsimmon",
"pathlike",
"Pathspec",
"POSIX",
"preact",
"preprocesses",
"Propogate",
"Proxied",
"rawlink",
"refreshable",
"REGEXES",
"regexmatch",
"regexreplace",
"regextest",
"Reinitiate",
"Repr",
"shumadrid",
"sourcemaps",
"striptime",
"subsources",
"subtags",
"succ",
"Syncthing",
"tsbuildinfo",
"tsdoc",
"tsdocs",
"tseslint",
"unregisters",
"vararg",
"varargs",
"vectorize",
"Vinzent",
"virtuallists",
"Wikilink",
"Wikilinks",
"Woelki"
],
"ignoreWords": [],
"import": [],
"enabled": true
}

88
eslint.config.mjs Normal file
View file

@ -0,0 +1,88 @@
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import svelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import { configs as devUtilsConfigs } from 'obsidian-dev-utils/ScriptUtils/ESLint/eslint.config';
import svelteParser from 'svelte-eslint-parser';
import typescriptEslint from 'typescript-eslint';
// import eslint = require('@eslint/js');
// import eslintConfigPrettier = require('eslint-config-prettier/flat');
export default typescriptEslint.config(
eslint.configs.recommended,
...typescriptEslint.configs.recommendedTypeChecked,
...svelte.configs['flat/recommended'],
...svelte.configs['flat/prettier'],
eslintPluginUnicorn.configs.recommended,
devUtilsConfigs,
eslintConfigPrettier,
{
files: ['**/*.{js,ts}'],
languageOptions: {
parser: typescriptEslint.parser,
parserOptions: {
// project: './tsconfig.json',
projectService: true
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: typescriptEslint.parser,
extraFileExtensions: ['.svelte'],
projectService: true
}
},
rules: {
'svelte/require-each-key': 'off'
}
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE']
}
],
'max-depth': ['error', 3],
eqeqeq: 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/require-await': 'error',
'unicorn/filename-case': [
'warn',
{ cases: { camelCase: true, pascalCase: true } }
],
// overrides
'default-case': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/restrict-template-expressions': [
'error',
{ allowNumber: true }
],
'perfectionist/sort-classes': [
'error',
{
type: 'unsorted'
}
],
'perfectionist/sort-modules': 'off',
'modules-newlines/import-declaration-newline': 'off',
'unicorn/no-null': 'off',
'unicorn/no-empty-file': 'warn',
'unicorn/prevent-abbreviations': 'warn'
}
},
{
ignores: ['node_modules/', 'dist/']
}
);

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"author": "shumadrid",
"authorUrl": "https://github.com/shumadrid/",
"description": "Uses Git to display dynamic vault and file changelogs in the sidebar.",
"id": "git-changelog",
"isDesktopOnly": true,
"name": "Git Changelog",
"version": "0.0.0",
"minAppVersion": "1.7.2"
}

12185
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

61
package.json Normal file
View file

@ -0,0 +1,61 @@
{
"name": "git-changelog",
"version": "0.0.0",
"description": "Uses Git to display dynamic vault and file changelogs in the sidebar.",
"keywords": [],
"license": "MIT",
"author": "shumadrid",
"type": "module",
"scripts": {
"build": "obsidian-dev-utils build",
"build:clean": "obsidian-dev-utils build:clean",
"build:compile": "obsidian-dev-utils build:compile",
"build:compile:svelte": "obsidian-dev-utils build:compile:svelte",
"build:compile:typescript": "obsidian-dev-utils build:compile:typescript",
"dev": "obsidian-dev-utils dev",
"format": "obsidian-dev-utils format",
"format:check": "obsidian-dev-utils format:check",
"lint": "obsidian-dev-utils lint",
"spellcheck": "obsidian-dev-utils spellcheck",
"version": "obsidian-dev-utils version"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/deep-equal": "^1.0.4",
"@types/node": "^22.13.1",
"@typescript-eslint/eslint-plugin": "^8.23.0",
"@typescript-eslint/parser": "^8.23.0",
"builtin-modules": "^5.0.0",
"color2k": "^2.0.3",
"compare-versions": "^6.1.1",
"deep-equal": "^2.2.3",
"esbuild": "^0.25.0",
"esbuild-svelte": "^0.9.0",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-exception-handling": "^1.5.4",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-svelte": "^3.0.3",
"eslint-plugin-unicorn": "^57.0.0",
"globals": "^16.0.0",
"jiti": "^2.4.2",
"lodash": "^4.17.21",
"obsidian": "^1.8.7",
"obsidian-dev-utils": "^19.8.2",
"obsidian-typings": "^2.35.0",
"prettier": "3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"sass": "^1.83.4",
"simple-git": "^3.27.0",
"spacetime": "^7.8.0",
"svelte": "^5.22.6",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^0.43.0",
"svelte-preprocess": "^6.0.3",
"typescript": "^5.8.2",
"typescript-eslint": "^8.25.0"
},
"overrides": {
"boolean": "npm:dry-uninstall"
}
}

View file

@ -0,0 +1,112 @@
import type GitChangelogPlugin from 'main.ts';
import {
addToQueue,
addToQueueAndWait
} from 'obsidian-dev-utils/obsidian/Queue';
export const GIT_OPERATION_TIMEOUT_MILLISECONDS = 8000;
export class ChangelogTaskManager {
public fileChangelogTasks = $state(0);
public isFileQueueEmpty = $derived(this.fileChangelogTasks === 0);
public vaultChangelogTasks = $state(0);
public isVaultQueueEmpty = $derived(this.vaultChangelogTasks === 0);
private fileChangelogController = new AbortController();
private vaultChangelogController = new AbortController();
private get tasks(): number {
return this.fileChangelogTasks + this.vaultChangelogTasks;
}
public constructor(private readonly plugin: GitChangelogPlugin) {}
public abortAll(): void {
this.fileChangelogController.abort();
this.vaultChangelogController.abort();
}
public async enqueueAndWait(
task: () => Promise<void>,
fileOrVault: 'file' | 'vault'
): Promise<void> {
this.incrementQueueSize(fileOrVault);
// Assuming all error handling is done within the task.
await addToQueueAndWait(
this.plugin.app,
task,
GIT_OPERATION_TIMEOUT_MILLISECONDS
);
this.decrementQueueSize(fileOrVault);
}
public enqueueSafely(
task: () => Promise<void>,
fileOrVault: 'file' | 'vault'
): void {
this.incrementQueueSize(fileOrVault);
// Passing everything in the callback is needed because errors are intercepted before
addToQueue(
this.plugin.app,
() => {
task()
.catch((error) => {
if (error instanceof Error) {
this.plugin.consoleDebug(error.message);
} else {
this.plugin.consoleDebug(`${fileOrVault} task failed`);
}
})
.finally(() => {
this.decrementQueueSize(fileOrVault);
});
},
GIT_OPERATION_TIMEOUT_MILLISECONDS
);
}
public abortPreviousTasksAndGetSignal(
fileOrVault: 'file' | 'vault'
): AbortSignal {
if (fileOrVault === 'file') {
this.fileChangelogController.abort();
this.fileChangelogController = new AbortController();
return this.fileChangelogController.signal;
}
this.vaultChangelogController.abort();
this.vaultChangelogController = new AbortController();
return this.vaultChangelogController.signal;
}
public getAbortSignal(fileOrVault: 'file' | 'vault'): AbortSignal {
return fileOrVault === 'file'
? this.fileChangelogController.signal
: this.vaultChangelogController.signal;
}
private decrementQueueSize(fileOrVault: 'file' | 'vault'): void {
if (fileOrVault === 'file') {
this.fileChangelogTasks--;
} else {
this.vaultChangelogTasks--;
}
}
/**
* Artificially track the vault and file changelog queues,
* because we need to determine if an empty changelog view is currently
* resetting/loading or just empty
*/
private incrementQueueSize(fileOrVault: 'file' | 'vault'): void {
if (fileOrVault === 'file') {
this.fileChangelogTasks++;
} else {
this.vaultChangelogTasks++;
}
if (this.tasks > 1) {
this.plugin.consoleDebug('queue size:', this.tasks);
}
}
}

View file

@ -0,0 +1,291 @@
import type { ObsidianGitPlugin } from 'gitPluginTypes.ts';
import type { Debouncer, PluginSettingTab } from 'obsidian';
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import type { SimpleGit } from 'simple-git';
import type {
FileChangelogEntry,
VaultChangelogEntry
} from 'Views/types.svelte.ts';
import { ChangelogTaskManager } from 'ChangelogTaskManager.svelte.ts';
import { debounce, ItemView, Notice } from 'obsidian';
import { PluginBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginBase';
import {
changelogGenerationSettingsUnchanged,
fileChangelogGenerationSettingsUnchanged,
statusBarSettingsUnchanged,
vaultChangelogGenerationSettingsUnchanged
} from 'settings/helper.ts';
import { GitChangelogPluginSettings } from 'settings/settings.ts';
import {
gitPluginCompatibleVersion,
gitPluginTestedVersion
} from 'settings/ui/GitPluginWarning.ts';
import {
FILE_CHANGELOG_VIEW_CONFIG,
FileChangelogView
} from 'Views/FileChangelog/FileChangelog.ts';
import { getActiveGitRelativeFile, isDiffView } from 'Views/helper.ts';
import {
VAULT_CHANGELOG_VIEW_CONFIG,
VaultChangelogView
} from 'Views/VaultChangelog/VaultChangelog.ts';
import type { ChangelogInterval } from './types.ts';
import { addCommands } from './commands.ts';
import { GitChangelogSettingsTab } from './settings/settingsTab.ts';
import { StatusBar } from './statusBar.ts';
import {
GitPluginIncompatibleVersionError,
GitPluginMissingError,
GitPluginState,
GitRepoMissingError
} from './types.ts';
export class GitChangelogPlugin extends PluginBase<GitChangelogPluginSettings> {
public cachedActiveGitFile = $state<string>();
public debouncedChangelogSettingsChangedCheck:
| Debouncer<[], void>
| undefined;
public gitPluginState = $state<GitPluginState>(GitPluginState.Uninitialized);
public gitRepoReady = $state<boolean>(false);
public dependenciesReady = $derived(
(this.gitPluginState === GitPluginState.Enabled ||
this.gitPluginState === GitPluginState.UntestedVersion) &&
this.gitRepoReady
);
public detectedTimeZone: string | undefined;
public fileChangelogCacheInterval?: ChangelogInterval;
public fileChangelogEntries: FileChangelogEntry[] | undefined = $state();
// Settings: IGitChangelogSettings;
public fileChangelogNewBatchEntries: FileChangelogEntry[] | undefined =
$state();
public settingsOfComputedCache?: ChangelogGenerationSettings;
public statusBar?: StatusBar;
public statusBarCachedTimeframe?: number;
public vaultChangelogCacheInterval?: ChangelogInterval;
public vaultChangelogEntries: undefined | VaultChangelogEntry[] = $state();
public vaultChangelogNewBatchEntries: VaultChangelogEntry[] = $state([]);
public changelogTaskManager!: ChangelogTaskManager;
public async addFileChangelogView(): Promise<void> {
await this.app.workspace.ensureSideLeaf(
FILE_CHANGELOG_VIEW_CONFIG.type,
'right',
{ reveal: false }
);
}
public async addVaultChangelogView(): Promise<void> {
await this.app.workspace.ensureSideLeaf(
VAULT_CHANGELOG_VIEW_CONFIG.type,
'left',
{ reveal: false }
);
}
public assignStatusBar(): void {
const statusBarElement = this.addStatusBarItem();
this.statusBar = new StatusBar(statusBarElement, this);
}
// eslint-disable-next-line no-magic-numbers
public displayError(data: unknown, timeout: number = 10 * 1000): void {
const error: Error = data instanceof Error ? data : new Error(String(data));
new Notice(`${this.manifest.name}\nError: ${error.message}`, timeout);
this.consoleDebug(`Error:`, error.stack);
}
// eslint-disable-next-line no-magic-numbers
public displayNotice(message: string, timeout: number = 3 * 1000): void {
new Notice(`${this.manifest.name}\n${message}`, timeout);
}
public async getGit(): Promise<SimpleGit> {
const gitPlugin = this.getGitPlugin();
if (await gitPlugin.isAllInitialized()) {
this.gitRepoReady = true;
return gitPlugin.gitManager.git;
}
this.gitRepoReady = false;
throw new GitRepoMissingError();
}
public getGitPlugin(): ObsidianGitPlugin {
const gitPlugin = this.app.plugins.getPlugin('obsidian-git');
if (gitPlugin) {
if (gitPluginCompatibleVersion(gitPlugin as ObsidianGitPlugin)) {
this.gitPluginState = gitPluginTestedVersion(
gitPlugin as ObsidianGitPlugin
)
? GitPluginState.Enabled
: GitPluginState.UntestedVersion;
return gitPlugin as ObsidianGitPlugin;
}
this.gitPluginState = GitPluginState.IncompatibleVersion;
throw new GitPluginIncompatibleVersionError();
} else {
this.gitPluginState = GitPluginState.Uninitialized;
this.gitRepoReady = false; // Not connected actually
throw new GitPluginMissingError();
}
}
public onSettingsSave(): void {
if (changelogGenerationSettingsUnchanged(this)) {
this.consoleDebug(
'Identical changelog settings',
this.settings.changelogGenerationSettings.gitDiffIgnore
);
if (
!fileChangelogGenerationSettingsUnchanged(this) &&
this.cachedActiveGitFile
) {
// This.fileChangelogCacheInterval = undefined;
this.app.workspace.trigger(
'obsidian-git-changelog:file-changelog-generation-settings-changed'
);
}
if (!statusBarSettingsUnchanged(this) && this.cachedActiveGitFile) {
// This.statusBarCachedTimeframe = undefined;
this.app.workspace.trigger(
'obsidian-git-changelog:status-bar-settings-changed'
);
}
if (!vaultChangelogGenerationSettingsUnchanged(this)) {
// This.vaultChangelogCacheInterval = undefined;
this.app.workspace.trigger(
'obsidian-git-changelog:vault-changelog-generation-settings-changed'
);
}
} else {
// So it's not redundantly triggered multiple times
this.app.workspace.trigger(
'obsidian-git-changelog:generation-settings-changed'
);
}
}
public updateActiveGitFile(): void {
try {
const currentActiveGitFile = getActiveGitRelativeFile(this);
if (currentActiveGitFile) {
if (currentActiveGitFile !== this.cachedActiveGitFile) {
this.cachedActiveGitFile = currentActiveGitFile;
this.app.workspace.trigger(
'obsidian-git-changelog:active-file-changed'
);
}
} else {
const currentActiveView =
this.app.workspace.getActiveViewOfType(ItemView);
if (isDiffView(currentActiveView)) {
return;
}
this.cachedActiveGitFile = undefined;
this.app.workspace.trigger(
'obsidian-git-changelog:active-file-changed'
);
}
} catch {
/* Empty */
}
}
// Functions below are from obsidian-dev-utils (https://github.com/mnaoumov/obsidian-dev-utils/blob/main/src/obsidian/Plugin/PluginBase.ts)
public override async saveSettings(
newSettings: GitChangelogPluginSettings,
checkForChanges = true
): Promise<void> {
await super.saveSettings(newSettings);
if (checkForChanges) {
this.debouncedChangelogSettingsChangedCheck?.();
}
}
protected override createPluginSettings(
data: unknown
): GitChangelogPluginSettings {
return new GitChangelogPluginSettings(data);
}
protected override createPluginSettingsTab(): null | PluginSettingTab {
return new GitChangelogSettingsTab(this);
}
// eslint-disable-next-line @typescript-eslint/require-await
protected override async onLayoutReady(): Promise<void> {
// This has to be initiated first. Handles all operations.
this.changelogTaskManager = new ChangelogTaskManager(this);
if (this.settings.statusBarStats) {
this.assignStatusBar();
}
this.registerEvent(
this.app.workspace.on('file-open', () => {
this.updateActiveGitFile();
})
);
// This.registerEvent(
// This.app.workspace.on('active-leaf-change', () => {
// This.clearActiveGitFileIfNoViewOpen();
// }),
// );
// Because of the case when the git plugin is re-enabled. Otherwise the file changelog view wouldn't refresh with the data from the current active note until the first file-open event.
this.registerEvent(
this.app.workspace.on('obsidian-git:head-change', () => {
this.updateActiveGitFile();
})
);
this.updateActiveGitFile();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.addVaultChangelogView();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.addFileChangelogView();
}
protected override onloadComplete(): void {
// Runs when plugin is unloaded.
this.register(() => {
this.debouncedChangelogSettingsChangedCheck?.cancel();
if (this.changelogTaskManager) {
this.changelogTaskManager.abortAll();
}
});
this.registerView(VAULT_CHANGELOG_VIEW_CONFIG.type, (leaf) => {
return new VaultChangelogView(leaf, this);
});
this.registerView(FILE_CHANGELOG_VIEW_CONFIG.type, (leaf) => {
return new FileChangelogView(leaf, this);
});
addCommands(this);
this.debouncedChangelogSettingsChangedCheck = debounce(
() => {
this.onSettingsSave();
},
// eslint-disable-next-line no-magic-numbers
500,
true
);
}
}

View file

@ -0,0 +1,372 @@
<script lang="ts">
import type { GitChangelogPlugin } from 'GitChangelogPlugin.svelte.ts';
import type { EventRef } from 'obsidian';
import { runCheckIgnore } from 'core/gitOperations/runCheckIgnore.ts';
import { appendChangelogEntries } from 'core/loadingEntries.ts';
import { updateChangelogEntries } from 'core/updatingEntries.ts';
import { onDestroy, onMount } from 'svelte';
import { isMoved, isRenamed } from 'utils.ts';
import ChangeIntervalButton from 'Views/components/ChangeIntervalButton.svelte';
import DependenciesStatusCheck from 'Views/components/DependenciesStatusCheck.svelte';
import DiffStatsComponent from 'Views/components/DiffStats.svelte';
import { composeAriaLabel, composeVersionTitle } from 'Views/formatters.ts';
import {
appendEntries,
changelogFileClick,
getActiveGitRelativeFile,
initialCommitReached
} from 'Views/helper.ts';
interface Properties {
plugin: GitChangelogPlugin;
}
const { plugin }: Properties = $props();
let observer: IntersectionObserver | undefined;
let headChangeReference: EventRef;
let settingsChangedReference: EventRef;
let sentinel: HTMLElement | undefined = $state();
const entries = $derived(plugin.fileChangelogEntries);
const hasEntries = $derived(entries !== undefined && entries.length > 0);
// Specific to file changelog
let activeFileChangedReference: EventRef;
let gitIgnoredFileOpen = $state(false);
let fileChangelogSettingsChangedReference: EventRef;
headChangeReference = plugin.app.workspace.on(
'obsidian-git:head-change',
() => {
if (plugin.changelogTaskManager) {
const abortSignal = plugin.changelogTaskManager.getAbortSignal('file');
plugin.changelogTaskManager.enqueueSafely(
() => tryUpdateEntries(abortSignal),
'file'
);
}
}
);
fileChangelogSettingsChangedReference = plugin.app.workspace.on(
'obsidian-git-changelog:file-changelog-generation-settings-changed',
() => {
resetFileChangelogSafely();
}
);
settingsChangedReference = plugin.app.workspace.on(
'obsidian-git-changelog:generation-settings-changed',
() => {
resetFileChangelogSafely();
}
);
activeFileChangedReference = plugin.app.workspace.on(
'obsidian-git-changelog:active-file-changed',
() => {
resetFileChangelogSafely();
}
);
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const initialLoading = async () => {
//
if (plugin.changelogTaskManager) {
const abortSignal =
plugin.changelogTaskManager.abortPreviousTasksAndGetSignal('file');
plugin.changelogTaskManager.enqueueSafely(async () => {
// Because active git file isn't cached at mount
await recomputeChangelog(abortSignal, getActiveGitRelativeFile(plugin));
}, 'file');
} else {
// eslint-disable-next-line no-promise-executor-return, no-magic-numbers
await new Promise((resolve) => setTimeout(resolve, 200));
await initialLoading();
}
};
onMount(async () => {
// We have to wait for some time to pass so that the changelog task manager gets initialized in the plugin's onLayoutReady method.
await initialLoading();
});
$effect(() => {
// Clean up existing observer first
if (observer) {
observer.disconnect();
observer = undefined;
}
if (entries !== undefined && sentinel) {
observer = new IntersectionObserver(
(observerEntries) => {
if (observerEntries[0].isIntersecting) {
const abortSignal =
plugin.changelogTaskManager.getAbortSignal('file');
handleScroll(abortSignal);
}
},
{
root: document.querySelector('.nav-files-container'),
rootMargin: '120px',
// eslint-disable-next-line no-magic-numbers
threshold: 0.1
}
);
observer.observe(sentinel);
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
return () => {
if (observer) {
observer.disconnect();
observer = undefined;
}
};
});
onDestroy(() => {
cleanupObserver();
plugin.app.workspace.offref(headChangeReference);
plugin.app.workspace.offref(settingsChangedReference);
plugin.app.workspace.offref(fileChangelogSettingsChangedReference);
plugin.app.workspace.offref(activeFileChangedReference);
// Observer = undefined;
});
function cleanupObserver(): void {
if (observer) {
observer.disconnect();
observer = undefined;
}
}
function resetFileChangelogSafely(): void {
const abortSignal =
plugin.changelogTaskManager.abortPreviousTasksAndGetSignal('file');
plugin.changelogTaskManager.enqueueSafely(async () => {
await recomputeChangelog(abortSignal);
}, 'file');
}
async function tryUpdateEntries(abortSignal: AbortSignal): Promise<void> {
if (
// AppendEntries handles the other case
entries !== undefined
) {
if (activeFilePotentiallyRenamed()) {
plugin.consoleDebug('active file potentially renamed');
plugin.updateActiveGitFile();
} else {
plugin.consoleDebug('active file not renamed, running update');
await updateChangelogEntries({
abortSignal,
fileOrVault: 'file',
filePath: plugin.cachedActiveGitFile,
plugin
});
}
}
}
/**
* This should ideally never trigger on user interaction but always automatically
*/
async function recomputeChangelog(
abortSignal: AbortSignal,
path: string | undefined = plugin.cachedActiveGitFile
): Promise<void> {
if (path === undefined) {
plugin.fileChangelogEntries = undefined;
} else {
await appendChangelogEntries({
abortSignal,
fileOrVault: 'file',
filePath: path,
plugin,
resetCache: true,
upperBoundaryCommit: undefined
});
}
// If the file git log yielded no entries, check if it's because the file is ignored by git, or if it's just a new file with no history
if (entries === undefined || entries.length === 0) {
// Replace with live activeGitFile check
gitIgnoredFileOpen =
!!plugin.cachedActiveGitFile &&
(await runCheckIgnore({
activeGitFile: plugin.cachedActiveGitFile,
plugin
}));
}
}
function handleScroll(abortSignal: AbortSignal): void {
plugin.changelogTaskManager.enqueueSafely(
() => loadMore(abortSignal),
'file'
);
}
async function loadMore(abortSignal: AbortSignal): Promise<void> {
if (
!initialCommitReached({
entries
}) &&
plugin.cachedActiveGitFile !== undefined
) {
await appendEntries({
abortSignal,
entries,
fileOrVault: 'file',
plugin
});
}
}
function primaryClick(event: MouseEvent, entryIndex: number | string): void {
if (entries === undefined || typeof entryIndex === 'string') {
return;
}
changelogFileClick(
event,
entries[entryIndex],
plugin,
entries[entryIndex + 1]?.commitHash,
entries[entryIndex].commitHash
);
}
function activeFilePotentiallyRenamed(): boolean {
if (entries !== undefined && entries.length > 0) {
return getActiveGitRelativeFile(plugin) !== entries[0].pathGitRelative;
}
return false;
}
</script>
<div class="git-changelog-view">
<!-- {#if true} -->
<div class="nav-header">
<div class="nav-buttons-container">
<ChangeIntervalButton
resetChangelog={recomputeChangelog}
enabled={hasEntries}
{plugin}
fileOrVault="file"
></ChangeIntervalButton>
</div>
</div>
<!-- {:else}
<div class="padding-top"></div>
{/if} -->
<DependenciesStatusCheck {plugin}>
<div class="nav-files-container">
{#if entries !== undefined}
{#each entries as entry, index}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="tree-item-self git-changelog-file-changelog-entry is-clickable nav-file-title"
aria-label={composeAriaLabel(entry)}
data-tooltip-position="bottom"
onclick={// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
(event) => {
primaryClick(event, index);
}}
>
<div class="git-changelog-changelog-date">
{composeVersionTitle({
interval: plugin.settings.fileChangelogInterval,
plugin,
timezoneAdjustedEntryDate: entry.timezoneAdjustedDate
})}
</div>
<div class="file-actions git-changelog-status-tag">
{#if entry.fromPathGitRelative === undefined}
<span
class="git-changelog-stat-color nav-file-tag"
data-type={entry.status}
>
Created
</span>
{:else}
{#if isMoved(entry)}
<span
class="git-changelog-stat-color nav-file-tag"
data-type="F"
>
Moved
</span>
{/if}
{#if isRenamed(entry)}
<span
class="git-changelog-stat-color nav-file-tag"
data-type="R"
>
Renamed
</span>
{/if}
{/if}
{#if entry.textDiffStats?.baseStats !== undefined && entry.textDiffStats.baseStats.additions + entry.textDiffStats.baseStats.deletions > 0}
<span class="git-changelog-margin-left">
<DiffStatsComponent
baseStats={entry.textDiffStats
? {
additions: entry.textDiffStats.baseStats.additions,
deletions: entry.textDiffStats.baseStats.deletions
}
: undefined}
inFileExplorer={false}
file={entry}
inFileChangelog={true}
/></span
>
{/if}
</div>
</div>
<!-- </div> -->
{/each}
<div bind:this={sentinel} id="sentinel"></div>
{:else if plugin.changelogTaskManager.isFileQueueEmpty}
{#if plugin.cachedActiveGitFile === undefined}
<div class="pane-empty">No markdown file opened.</div>
{:else if gitIgnoredFileOpen}
<div class="pane-empty">File is ignored by Git.</div>
{:else}
<div class="pane-empty">File isn't a markdown file.</div>
{/if}
{/if}
</div>
</DependenciesStatusCheck>
</div>
<style lang="scss">
.git-changelog-file-changelog-entry {
display: flex;
align-items: center;
width: 100%;
padding-left: var(--size-4-2);
}
.git-changelog-margin-left {
margin-left: var(--size-2-3);
}
.git-changelog-status-tag {
display: flex;
align-items: center;
padding-right: 0px;
overflow: hidden;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
width: 100%;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,64 @@
import type GitChangelogPlugin from 'main.ts';
import type { WorkspaceLeaf } from 'obsidian';
import { ItemView } from 'obsidian';
import { mount, unmount } from 'svelte';
import FileChangelogComponent from './FileChangelog.svelte';
export const FILE_CHANGELOG_VIEW_CONFIG = {
icon: 'file-clock',
name: 'File Changelog',
type: 'file-changelog-view'
};
export class FileChangelogView extends ItemView {
public plugin: GitChangelogPlugin;
private _view: FileChangelogComponent | undefined = undefined;
public constructor(leaf: WorkspaceLeaf, plugin: GitChangelogPlugin) {
super(leaf);
this.plugin = plugin;
this.navigation = false;
}
public async destroy(): Promise<void> {
if (this._view) {
await unmount(this._view);
this._view = undefined;
}
}
public override getDisplayText(): string {
return FILE_CHANGELOG_VIEW_CONFIG.name;
}
public override getIcon(): string {
return FILE_CHANGELOG_VIEW_CONFIG.icon;
}
public override getViewType(): string {
return FILE_CHANGELOG_VIEW_CONFIG.type;
}
public override async onClose(): Promise<void> {
await this.destroy();
return super.onClose();
}
// eslint-disable-next-line @typescript-eslint/require-await
public override async onOpen(): Promise<void> {
this._view = mount(FileChangelogComponent, {
props: {
plugin: this.plugin
},
target: this.contentEl
}) as FileChangelogComponent;
}
public override onunload(): void {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.destroy();
}
}

View file

@ -0,0 +1,290 @@
<script lang="ts">
import type GitChangelogPlugin from 'main.ts';
import type { EventRef } from 'obsidian';
// Import { SimpleGit } from "src/gitManager/simpleGit";
import { TOGGLE_FILES_SUMMARY_OPTION_ICON } from 'constants.ts';
import { appendChangelogEntries } from 'core/loadingEntries.ts';
import { updateChangelogEntries } from 'core/updatingEntries.ts';
import { setIcon } from 'obsidian';
import { onDestroy, onMount } from 'svelte';
import { FilesSummariesDisplayMode } from 'types.ts';
import { appendEntries, initialCommitReached } from 'Views/helper.ts';
import ChangeIntervalButton from '../components/ChangeIntervalButton.svelte';
import DependenciesStatusCheck from '../components/DependenciesStatusCheck.svelte';
import VersionComponent from './components/Version.svelte';
interface Properties {
plugin: GitChangelogPlugin;
}
const { plugin }: Properties = $props();
let collapseButton: HTMLElement | undefined;
let filesSummaryDisplayModeButton: HTMLElement | undefined;
let observer: IntersectionObserver | undefined;
let headChangeReference: EventRef;
let settingsChangedReference: EventRef;
let vaultChangelogSettingsChangedReference: EventRef;
const entries = $derived(plugin.vaultChangelogEntries);
let sentinel: HTMLElement | undefined = $state();
// Let compactMode = $state(checkIfViewIsInStack());
// Let isInSplit: EventRef;
let showFilesCountSummaries = $state(
plugin.settings.fileSummariesDisplayMode
);
const hasEntries = $derived(
entries !== undefined && entries.length > 0 && plugin.dependenciesReady
);
const allEntriesCollapsed = $derived(
entries?.every((entry) => entry.isCollapsed ?? true)
);
headChangeReference = plugin.app.workspace.on(
'obsidian-git:head-change',
() => {
plugin
.getGit()
.then(() => {
const abortSignal =
plugin.changelogTaskManager.getAbortSignal('vault');
plugin.changelogTaskManager.enqueueSafely(
() => tryUpdateEntries(abortSignal),
'vault'
);
})
// eslint-disable-next-line @typescript-eslint/no-empty-function
.catch(() => {});
}
);
vaultChangelogSettingsChangedReference = plugin.app.workspace.on(
'obsidian-git-changelog:vault-changelog-generation-settings-changed',
() => {
resetVaultChangelogSafely();
}
);
settingsChangedReference = plugin.app.workspace.on(
'obsidian-git-changelog:generation-settings-changed',
() => {
resetVaultChangelogSafely();
}
);
// IsInSplit = plugin.app.workspace.on('layout-change', () => {
// CompactMode = checkIfViewIsInStack();
// });
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const initialLoading = async () => {
if (plugin.changelogTaskManager) {
resetVaultChangelogSafely();
} else {
// eslint-disable-next-line no-promise-executor-return, no-magic-numbers
await new Promise((resolve) => setTimeout(resolve, 200));
await initialLoading();
}
};
onMount(async () => {
// We have to wait for some time to pass so that the changelog task manager gets initialized in the plugin's onLayoutReady method.
await initialLoading();
});
$effect(() => {
if (collapseButton) {
setIcon(
collapseButton,
allEntriesCollapsed ? 'chevrons-up-down' : 'chevrons-down-up'
);
}
if (filesSummaryDisplayModeButton) {
setIcon(filesSummaryDisplayModeButton, TOGGLE_FILES_SUMMARY_OPTION_ICON);
}
});
onDestroy(() => {
cleanupObserver();
plugin.app.workspace.offref(headChangeReference);
plugin.app.workspace.offref(settingsChangedReference);
// Plugin.app.workspace.offref(isInSplit);
plugin.app.workspace.offref(vaultChangelogSettingsChangedReference);
// Plugin.app.workspace.offref(layoutReadyReference);
filesSummaryDisplayModeButton = undefined;
collapseButton = undefined;
});
function resetVaultChangelogSafely(): void {
// We want to immediately cancel all current operations for the vault changelog and schedule the rest of the operation in a queue.
const abortSignal =
plugin.changelogTaskManager.abortPreviousTasksAndGetSignal('vault');
plugin.changelogTaskManager.enqueueSafely(async () => {
await recomputeChangelog(abortSignal);
}, 'vault');
}
function cleanupObserver(): void {
if (observer) {
observer.disconnect();
observer = undefined;
}
}
function initializeObserver(): void {
cleanupObserver();
if (entries !== undefined && sentinel) {
observer = new IntersectionObserver(
(observerEntries) => {
if (observerEntries[0].isIntersecting) {
const abortSignal =
plugin.changelogTaskManager.getAbortSignal('vault');
handleScroll(abortSignal);
}
},
{
root: document.querySelector('.nav-files-container'),
rootMargin: '120px',
// eslint-disable-next-line no-magic-numbers
threshold: 0.1
}
);
observer.observe(sentinel);
}
}
// React to both entries and sentinel changes
$effect(() => {
if (entries !== undefined && sentinel) {
initializeObserver();
}
});
async function tryUpdateEntries(abortSignal: AbortSignal): Promise<void> {
if (plugin.vaultChangelogEntries === undefined) {
plugin.consoleDebug(
"If git plugin wasn't just re-enabled, then a redundant vault changelog recomputation occurred!"
);
await recomputeChangelog(abortSignal);
} else {
await updateChangelogEntries({
abortSignal,
fileOrVault: 'vault',
plugin
});
}
}
async function recomputeChangelog(abortSignal: AbortSignal): Promise<void> {
await appendChangelogEntries({
abortSignal,
fileOrVault: 'vault',
filePath: undefined,
plugin,
resetCache: true,
upperBoundaryCommit: undefined
});
}
function handleScroll(abortSignal: AbortSignal): void {
plugin.changelogTaskManager.enqueueSafely(
() => loadMore(abortSignal),
'vault'
);
}
async function loadMore(abortSignal: AbortSignal): Promise<void> {
if (!initialCommitReached({ entries })) {
await appendEntries({
abortSignal,
entries,
fileOrVault: 'vault',
plugin
});
}
}
function toggleFilesSummaryOption(): void {
showFilesCountSummaries =
showFilesCountSummaries === FilesSummariesDisplayMode.Total
? FilesSummariesDisplayMode.TextAndBinary
: FilesSummariesDisplayMode.Total;
const newSettings = plugin.settingsClone;
newSettings.fileSummariesDisplayMode = showFilesCountSummaries;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
plugin.saveSettings(newSettings);
}
function toggleCollapsedState(): void {
// AllEntriesCollapsed needs to be cached because it is a reactive derived value that won't have a consistent state across the whole loop.
const everythingCollapsed = allEntriesCollapsed;
if (entries)
for (const entry of entries) entry.isCollapsed = !everythingCollapsed;
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="git-changelog-view">
<!-- {#if !compactMode} -->
<div class="nav-header">
<div class="nav-buttons-container">
<div
id="layoutChange"
class="clickable-icon nav-action-button"
data-icon={allEntriesCollapsed
? 'chevrons-up-down'
: 'chevrons-down-up'}
aria-disabled={!hasEntries}
aria-label={allEntriesCollapsed ? 'Expand All' : 'Collapse All'}
bind:this={collapseButton}
onclick={toggleCollapsedState}
></div>
<ChangeIntervalButton
enabled={hasEntries}
resetChangelog={recomputeChangelog}
{plugin}
fileOrVault="vault"
></ChangeIntervalButton>
<div
id="filesSummaryChange"
class="clickable-icon nav-action-button"
data-icon={TOGGLE_FILES_SUMMARY_OPTION_ICON}
aria-disabled={!hasEntries}
aria-label={showFilesCountSummaries === FilesSummariesDisplayMode.Total
? 'Text/media summary stats'
: 'Total files summary stats'}
bind:this={filesSummaryDisplayModeButton}
onclick={toggleFilesSummaryOption}
></div>
</div>
</div>
<!-- {/if} -->
<DependenciesStatusCheck {plugin}>
<div class="nav-files-container">
{#if entries && entries.length > 0}
{#each entries as version}
<div class="tree-item nav-folder mod-root">
<VersionComponent {version} {plugin} {showFilesCountSummaries} />
</div>
{/each}
<div bind:this={sentinel} id="sentinel"></div>
{:else if plugin.changelogTaskManager.isVaultQueueEmpty}
<div class="pane-empty">No commits detected.</div>
{/if}
</div>
</DependenciesStatusCheck>
</div>
<style lang="scss">
</style>

View file

@ -0,0 +1,63 @@
import type GitChangelogPlugin from 'main.ts';
import type { WorkspaceLeaf } from 'obsidian';
import { ItemView } from 'obsidian';
import { mount, unmount } from 'svelte';
import VaultChangelogComponent from './VaultChangelog.svelte';
export const VAULT_CHANGELOG_VIEW_CONFIG = {
icon: 'folder-clock',
name: 'Vault Changelog',
type: 'vault-changelog-view'
};
export class VaultChangelogView extends ItemView {
public plugin: GitChangelogPlugin;
private _view: undefined | VaultChangelogComponent = undefined;
public constructor(leaf: WorkspaceLeaf, plugin: GitChangelogPlugin) {
super(leaf);
this.plugin = plugin;
this.navigation = false;
}
public async destroy(): Promise<void> {
if (this._view) {
await unmount(this._view);
this._view = undefined;
}
}
public override getDisplayText(): string {
return VAULT_CHANGELOG_VIEW_CONFIG.name;
}
public override getIcon(): string {
return VAULT_CHANGELOG_VIEW_CONFIG.icon;
}
public override getViewType(): string {
return VAULT_CHANGELOG_VIEW_CONFIG.type;
}
public override async onClose(): Promise<void> {
await this.destroy();
return super.onClose();
}
// eslint-disable-next-line @typescript-eslint/require-await
public override async onOpen(): Promise<void> {
this._view = mount(VaultChangelogComponent, {
props: {
plugin: this.plugin
},
target: this.contentEl
}) as VaultChangelogComponent;
}
public override onunload(): void {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.destroy();
}
}

View file

@ -0,0 +1,172 @@
<script lang="ts">
import type GitChangelogPlugin from 'main.ts';
import type { DiffFile } from 'types.ts';
import { OPEN_FILE_ICON } from 'constants.ts';
import { isFileRenamedOrMoved } from 'core/gitOperations/helper.ts';
import { setIcon, TFile } from 'obsidian';
import { DiffFileStatus } from 'types.ts';
import { mayTriggerFileMenu } from 'utils.ts';
import {
changelogFileClick,
getDisplayPath,
openFile
} from 'Views/helper.ts';
import { VaultChangelogView } from 'Views/VaultChangelog/VaultChangelog.ts';
import DiffStatsComponent from '../../components/DiffStats.svelte';
interface Properties {
currentDayCommitHash: string;
file: DiffFile;
plugin: GitChangelogPlugin;
previousDayLastCommitHash?: string;
}
const {
currentDayCommitHash,
file,
plugin,
previousDayLastCommitHash
}: Properties = $props();
const buttons: HTMLElement[] = $state([]);
const ariaLabel = $derived(
isFileRenamedOrMoved(file.status)
? `${file.fromPathGitRelative} →\n${file.pathGitRelative}`
: file.pathGitRelative
);
const fileStatusText = $derived(
isFileRenamedOrMoved(file.status) ? DiffFileStatus.Renamed : file.status
);
// This isn't perfect because some old file path could match an unrelated file's current path in the current state of the vault.
const relativeVaultPath = $state(
plugin.getGitPlugin().gitManager.getRelativeVaultPath(file.pathGitRelative)
);
const tFile = $derived(
plugin.app.vault.getAbstractFileByPath(relativeVaultPath)
);
$effect(() => {
for (const b of buttons) {
if (b) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setIcon(b, b.getAttr('data-icon')!);
}
}
});
function mainClick(event: MouseEvent): void {
changelogFileClick(
event,
file,
plugin,
previousDayLastCommitHash ?? currentDayCommitHash,
currentDayCommitHash
);
}
function openVaultFile(event: MouseEvent): void {
openFile({ event, file, plugin, relativeVaultPath });
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="tree-item-self git-changelog-align-file nav-file-title
'is-clickable'"
data-path={relativeVaultPath}
data-tooltip-position="bottom"
aria-label={ariaLabel}
onclick={mainClick}
onauxclick={// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
(event) => {
event.stopPropagation();
// eslint-disable-next-line no-magic-numbers, eqeqeq
if (event.button == 2) {
const view = plugin.app.workspace.getActiveViewOfType(VaultChangelogView);
if (view) {
mayTriggerFileMenu({
app: plugin.app,
event,
filePath: relativeVaultPath,
source: 'git-source-control',
view: view.leaf
});
}
} else {
mainClick(event);
}
}}
data-type={file.status}
>
<div class="git-changelog-file-name-container">
<div class="git-changelog-one-line">
{getDisplayPath(file.pathGitRelative)}
</div>
{#if tFile instanceof TFile && !!plugin.app.viewRegistry?.getTypeByExtension(tFile.extension)}
<div
data-icon={OPEN_FILE_ICON}
aria-label="Open File"
bind:this={buttons[0]}
onauxclick={openVaultFile}
onclick={openVaultFile}
class="clickable-icon open-file-icon"
></div>
{/if}
</div>
<div class="diff-file-stats">
<DiffStatsComponent
inFileChangelog={false}
baseStats={file.textDiffStats
? {
additions: file.textDiffStats.baseStats.additions,
deletions: file.textDiffStats.baseStats.deletions
}
: undefined}
{file}
/>
<span
class="git-changelog-stat-color git-changelog-file-status-letter"
data-type={file.status}
>
<!-- {#if file.status !== DiffFileStatus.Modified} -->
{fileStatusText}
<!-- {/if} -->
</span>
</div>
</div>
<style lang="scss">
.diff-file-stats {
display: flex;
align-items: center;
gap: var(--size-4-3);
padding-right: 0px;
flex: 1 1 auto;
justify-content: flex-end;
}
.git-changelog-align-file {
align-items: center;
}
.git-changelog-file-name-container {
display: flex;
min-width: 54px;
margin-right: var(--size-2-3);
gap: var(--size-4-1);
.open-file-icon {
padding: 0 4px;
height: 16px;
min-width: min-content;
}
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import type { FilesSummary } from 'types.ts';
import {
FILE_ADDED_ICON,
FILE_DELETED_ICON,
FILE_MODIFIED_ICON,
FILE_RENAMED_ICON
} from 'constants.ts';
import { setIcon } from 'obsidian';
import { DiffFileStatus } from 'types.ts';
interface Properties {
filesSummary: FilesSummary;
}
interface SummaryStat {
count: number;
icon: string;
type: DiffFileStatus;
}
const { filesSummary }: Properties = $props();
const iconElements = $state<Record<string, HTMLElement>>({});
const stats = $derived<SummaryStat[]>(
[
{
count: filesSummary.addedFiles,
icon: FILE_ADDED_ICON,
type: DiffFileStatus.Added
},
{
count: filesSummary.modifiedFiles,
icon: FILE_MODIFIED_ICON,
type: DiffFileStatus.Modified
},
{
count: filesSummary.renamedFiles,
icon: FILE_RENAMED_ICON,
type: DiffFileStatus.Renamed
},
{
count: filesSummary.deletedFiles,
icon: FILE_DELETED_ICON,
type: DiffFileStatus.Deleted
}
].filter((stat) => stat.count > 0)
);
$effect(() => {
for (const [type, element] of Object.entries(iconElements)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
const stat = stats.find((s) => s.type === type);
if (stat) {
setIcon(element, stat.icon);
}
}
});
</script>
{#each stats as stat}
<span class="git-changelog-stat-item">
<span
class="icon git-changelog-stat-color"
data-type={stat.type}
bind:this={iconElements[stat.type]}
></span>
<span class="number git-changelog-stat-color" data-type={stat.type}>
{stat.count}
</span>
</span>
{/each}
<style lang="scss">
.git-changelog-stat-item {
.number {
font-weight: var(--font-medium);
}
}
</style>

View file

@ -0,0 +1,172 @@
<script lang="ts">
import type GitChangelogPlugin from 'main.ts';
import type { VaultChangelogEntry } from 'Views/types.svelte.ts';
import { slide } from 'svelte/transition';
import { DiffFileStatus, FilesSummariesDisplayMode } from 'types.ts';
import { composeVersionTitle } from 'Views/formatters.ts';
import DiffStatsComponent from '../../components/DiffStats.svelte';
import FileComponent from './File.svelte';
import DayFilesStatusComponent from './FileSummariesStats.svelte';
interface Properties {
plugin: GitChangelogPlugin;
showFilesCountSummaries: FilesSummariesDisplayMode;
version: VaultChangelogEntry;
}
const { plugin, showFilesCountSummaries, version }: Properties = $props();
const formattedDate = $derived(
composeVersionTitle({
interval: plugin.settings.vaultChangelogInterval,
plugin,
timezoneAdjustedEntryDate: version.timezoneAdjustedDate
})
);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if !version.isInitialCommit()}
<div class="tree-item nav-folder" class:is-collapsed={version.isCollapsed}>
<div
class="tree-item-self is-clickable nav-folder-title git-changelog-bottom-padding"
data-tooltip-position="bottom"
onclick={/* eslint-disable-next-line @typescript-eslint/explicit-function-return-type */
() => {
version.isCollapsed = !version.isCollapsed;
}}
>
{#if !version.isInitialCommit()}
<div
class="tree-item-icon nav-folder-collapse-indicator collapse-icon"
class:is-collapsed={version.isCollapsed}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="svg-icon right-triangle"><path d="M3 8L12 17L21 8" /></svg
>
</div>
{/if}
<div class="file-stats git-changelog-files-summaries-stats">
<div class="git-changelog-entry-title">{formattedDate}</div>
<!-- if more than one option is selected then show labels -->
{#if showFilesCountSummaries === FilesSummariesDisplayMode.Total}
<div class="git-changelog-stat">
<DayFilesStatusComponent
filesSummary={version.getChangelogFilesSummary()}
/>
</div>
{/if}
{#if showFilesCountSummaries === FilesSummariesDisplayMode.TextAndBinary}
{#if version.binaryFiles.length > 0}
<div class="git-changelog-stat">
<DayFilesStatusComponent
filesSummary={version.binaryFilesSummaryCached}
/>
<span
class="nav-file-tag git-changelog-tag git-changelog-summary-type-tag"
>MEDIA</span
>
</div>
{/if}
{#if version.textFiles.length > 0}
<div class="git-changelog-stat">
<DayFilesStatusComponent
filesSummary={version.textFilesSummaryCached}
/>
<span
class="nav-file-tag git-changelog-tag git-changelog-summary-type-tag"
>TEXT</span
>
</div>
{/if}
{/if}
<DiffStatsComponent
inFileChangelog={false}
baseStats={{
additions: version.getChangelogContentAdditions(),
deletions: version.getChangelogContentDeletions()
}}
/>
</div>
</div>
{#if !version.isCollapsed}
<div
class="tree-item-children nav-folder-children"
transition:slide|local={// eslint-disable-next-line no-magic-numbers
{ duration: 150 }}
>
{#each version.textFiles as file}
{#if file !== undefined && file !== undefined}
<FileComponent
{file}
currentDayCommitHash={version.commitHash}
previousDayLastCommitHash={version.previousDayLastCommitHash}
{plugin}
/>
{/if}
{/each}
{#each version.binaryFiles as file}
{#if file !== undefined && file !== undefined}
<FileComponent
{file}
currentDayCommitHash={version.commitHash}
previousDayLastCommitHash={version.previousDayLastCommitHash}
{plugin}
/>
{/if}
{/each}
</div>
{/if}
</div>
{:else}
<div class="git-changelog-margin-top">
<span
class="git-changelog-stat-color nav-file-tag git-changelog-tag"
data-type={DiffFileStatus.Modified}
>
Initial version
</span>
</div>
{/if}
<style lang="scss">
.git-changelog-files-summaries-stats {
display: flex;
align-items: start;
flex-direction: column;
}
.git-changelog-tag {
font-size: 0.72em;
padding: var(--size-4-1) var(--size-4-2);
}
.git-changelog-margin-top {
margin-top: var(--size-4-2);
padding-left: var(--size-4-3);
}
.git-changelog-summary-type-tag {
margin-left: 0px;
padding: 0px var(--size-4-1);
font-weight: var(--font-normal);
}
.git-changelog-bottom-padding {
padding-bottom: 2px;
}
</style>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import type GitChangelogPlugin from 'main.ts';
import { CHANGE_INTERVAL_ICON } from 'constants.ts';
import { setIcon } from 'obsidian';
import { setNextChangelogInterval } from 'settings/validation/changelogInterval.ts';
import { onDestroy } from 'svelte';
interface Properties {
enabled: boolean;
fileOrVault: 'file' | 'vault';
plugin: GitChangelogPlugin;
resetChangelog: (abortSignal: AbortSignal) => Promise<void>;
}
const {
enabled,
fileOrVault,
plugin,
resetChangelog: recomputeChangelog
}: Properties = $props();
let isChangingInterval = $state(false);
let button: HTMLElement | undefined;
$effect(() => {
if (button) {
setIcon(button, CHANGE_INTERVAL_ICON);
}
});
onDestroy(() => {
button = undefined;
});
async function onClick(): Promise<void> {
const abortSignal =
plugin.changelogTaskManager.abortPreviousTasksAndGetSignal(fileOrVault);
await plugin.changelogTaskManager.enqueueAndWait(
async () => {
if (isChangingInterval) return;
isChangingInterval = true;
try {
await setNextChangelogInterval(plugin, fileOrVault);
await recomputeChangelog(abortSignal);
} catch (error) {
if (error instanceof Error) {
plugin.consoleDebug(error.message);
}
} finally {
// If onClick cant run concurrently (e.g. always in a queue), then it's ok to modify isChangingInterval like this.
// eslint-disable-next-line require-atomic-updates
isChangingInterval = false;
}
},
fileOrVault
);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
id="changeInterval"
class="clickable-icon nav-action-button"
data-icon={CHANGE_INTERVAL_ICON}
aria-label="Change Interval"
aria-disabled={isChangingInterval || !enabled}
bind:this={button}
onclick={onClick}
></div>
<style lang="scss">
</style>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import type GitChangelogPlugin from 'main.ts';
import type { Snippet } from 'svelte';
import { GitPluginState } from 'types.ts';
interface Properties {
children?: Snippet;
plugin: GitChangelogPlugin;
}
const { children, plugin }: Properties = $props();
</script>
{#if plugin.gitPluginState === GitPluginState.Uninitialized}
<div class="pane-empty git-changelog-git-issue">
⚠️ This plugin requires the Git plugin to be installed & enabled.
</div>
{:else if plugin.gitPluginState === GitPluginState.IncompatibleVersion}
<div class="pane-empty git-changelog-git-issue">
⚠️ Current version of the Git plugin is incompatible.
</div>
{:else if !plugin.gitRepoReady}
<div class="pane-empty git-changelog-git-issue">
⚠️ Can't detect a valid active git repository. Please ensure you have
configured a valid Git repository with the Git plugin.
</div>
{:else}
{@render children?.()}
{/if}
<style>
.git-changelog-git-issue {
color: var(--text-warning);
padding: 0 var(--size-4-6);
opacity: var(--git-changelog-opacity);
}
</style>

View file

@ -0,0 +1,97 @@
<script lang="ts">
import type {
DiffFile,
StatEntry,
TextDiffBaseStats,
TextDiffMoveStats
} from 'types.ts';
import {
ADDITIONS_ICON,
DELETIONS_ICON,
MINUS_ICON,
PLUS_ICON
} from 'constants.ts';
import { formatDiffFileType } from 'Views/formatters.ts';
import StatComponent from './Stat.svelte';
interface Properties {
baseStats?: TextDiffBaseStats;
file?: DiffFile;
inFileChangelog: boolean;
inFileExplorer?: boolean;
moveStats?: TextDiffMoveStats;
}
const { baseStats, file, inFileChangelog }: Properties = $props();
const isFileStat = $derived(file !== undefined);
const stats = $derived<StatEntry[]>([
{
count: baseStats?.additions ?? 0,
icon: isFileStat ? PLUS_ICON : ADDITIONS_ICON,
type: 'Additions'
},
{
count: baseStats?.deletions ?? 0,
icon: isFileStat ? MINUS_ICON : DELETIONS_ICON,
type: 'Deletions'
}
]);
// BUG: not reactive?
const findIndex = $derived(
stats.findIndex((stat) => stat.count !== undefined && stat.count > 0)
);
const firstAppearingStat = $derived(findIndex === -1 ? Infinity : findIndex);
// BUG: not reactive
const statVisibilities = $derived(
stats.map((stat) => stat.count !== undefined && stat.count > 0)
);
</script>
<div
class=" {isFileStat ? 'git-changelog-stats-container' : 'git-changelog-stat'}"
>
{#if baseStats === undefined && isFileStat}
{#if !inFileChangelog}
<span class="nav-file-tag git-changelog-file-type-tag">
{formatDiffFileType(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file!
)}
</span>
{/if}
{:else if !stats.some((stat) => stat.count > 0) && !isFileStat}
<StatComponent {isFileStat} isInvisible={false} />
{:else}
{#each stats as stat, index}
{#if statVisibilities[index] || firstAppearingStat < index}
<StatComponent
{stat}
{isFileStat}
isInvisible={!statVisibilities[index]}
/>
{/if}
{/each}
{/if}
</div>
<style lang="scss">
.git-changelog-stats-container {
display: flex;
align-items: center;
height: 100%;
vertical-align: middle;
gap: var(--size-4-3);
justify-content: flex-end;
}
.git-changelog-file-type-tag {
margin-right: var(--size-2-1);
margin-left: 0;
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import type { StatEntry } from 'types.ts';
import { ADDITIONS_ICON } from 'constants.ts';
import { setIcon } from 'obsidian';
interface Properties {
isFileStat: boolean;
isInvisible: boolean;
stat?: StatEntry;
}
const { isFileStat, isInvisible, stat }: Properties = $props();
let iconElement: HTMLElement;
$effect(() => {
if (iconElement) {
if (stat) {
setIcon(iconElement, stat.icon);
} else {
setIcon(iconElement, ADDITIONS_ICON);
}
}
});
function getStatType(): string {
return stat?.type ?? 'M';
}
function getStatCount(): number {
return stat?.count ?? 0;
}
</script>
<span
class={isFileStat
? 'git-changelog-stat-item-file'
: 'git-changelog-stat-item'}
>
<span
class="{isFileStat ? 'file-icon' : 'icon'} git-changelog-stat-color"
class:invisible={isInvisible}
data-type={getStatType()}
bind:this={iconElement}
></span>
<span
class="number git-changelog-stat-color"
class:invisible={isInvisible}
data-type={getStatType()}
>
{getStatCount()}
</span>
</span>
<style lang="scss">
</style>

209
src/Views/formatters.ts Normal file
View file

@ -0,0 +1,209 @@
import type GitChangelogPlugin from 'main.ts';
import type { Spacetime } from 'spacetime';
import type { DiffFile } from 'types.ts';
import { normalizePath } from 'obsidian';
import { getTimeZone } from 'settings/ui/CustomTimeZone.ts';
import { getDayStartTime } from 'settings/ui/DayStartTime.ts';
import { getUserLocale } from 'settings/validation/userLocale.ts';
import spacetime from 'spacetime';
import { applyDayStartTimeSetting } from 'timeUtils.ts';
import { ChangelogInterval } from 'types.ts';
import { getFileNameFromPath, isMoved, isRenamed } from 'utils.ts';
export function composeAriaLabel(file: DiffFile): string {
try {
let ariaString = '';
if (isMoved(file)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ariaString += file.fromPathGitRelative!;
ariaString += ' →';
ariaString += '\n';
ariaString += file.pathGitRelative;
} else if (isRenamed(file)) {
ariaString += getFileNameFromPath({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
normalizedFilePath: normalizePath(file.fromPathGitRelative!)
});
ariaString += ' → ';
ariaString += getFileNameFromPath({
normalizedFilePath: normalizePath(file.pathGitRelative)
});
}
return ariaString;
} catch {
return '';
}
}
export function composeDailyVersionDisplayText(
fullyAdjustedCurrentDate: Spacetime,
entryDate: Spacetime,
plugin: GitChangelogPlugin
): string {
const isToday = entryDate.isSame(fullyAdjustedCurrentDate, 'day');
const isYesterday = entryDate.isSame(
fullyAdjustedCurrentDate.clone().subtract(1, 'days'),
'day'
);
if (isToday) {
return 'Today';
}
if (isYesterday) {
return 'Yesterday';
}
return formatDate(entryDate.toNativeDate(), getUserLocale(plugin));
}
export function composeHourlyVersionDisplayText(
fullyAdjustedCurrentDate: Spacetime,
entryDate: Spacetime,
plugin: GitChangelogPlugin
): string {
const hoursDifference = entryDate
.startOf('hour')
.diff(fullyAdjustedCurrentDate.startOf('hour'), 'hours');
if (hoursDifference === 0) {
return 'This Hour';
}
if (hoursDifference === 1) {
return '1 hour ago';
}
// eslint-disable-next-line no-magic-numbers
if (hoursDifference <= 48) {
return `${hoursDifference} hours ago`;
}
return formatDateWithHour(
entryDate.startOf('hour').toNativeDate(),
getUserLocale(plugin)
);
}
export function composeMonthlyVersionDisplayText(
entryDate: Spacetime,
plugin: GitChangelogPlugin
): string {
return formatMonthYear(entryDate.toNativeDate(), getUserLocale(plugin));
}
export function composeVersionTitle({
interval,
plugin,
timezoneAdjustedEntryDate
}: {
interval: ChangelogInterval;
plugin: GitChangelogPlugin;
timezoneAdjustedEntryDate: Spacetime;
}): string {
const timezoneAdjustedCurrentDate = spacetime(
new Date(),
getTimeZone(plugin.settings.changelogGenerationSettings, plugin)
);
const fullyAdjustedCurrentDate = applyDayStartTimeSetting({
dayStartTime: getDayStartTime(plugin.settings.changelogGenerationSettings),
timezoneAdjustedDate: timezoneAdjustedCurrentDate
});
const fullyAdjustedEntryDate = applyDayStartTimeSetting({
dayStartTime: getDayStartTime(plugin.settings.changelogGenerationSettings),
timezoneAdjustedDate: timezoneAdjustedEntryDate
});
switch (interval) {
case ChangelogInterval.Hourly: {
return composeHourlyVersionDisplayText(
timezoneAdjustedCurrentDate,
timezoneAdjustedEntryDate,
plugin
);
}
case ChangelogInterval.Monthly: {
return composeMonthlyVersionDisplayText(fullyAdjustedEntryDate, plugin);
}
case ChangelogInterval.Weekly: {
return composeWeeklyVersionDisplayText(
fullyAdjustedCurrentDate,
fullyAdjustedEntryDate
);
}
default: {
return composeDailyVersionDisplayText(
fullyAdjustedCurrentDate,
fullyAdjustedEntryDate,
plugin
);
}
}
}
export function composeWeeklyVersionDisplayText(
fullyAdjustedCurrentDate: Spacetime,
entryDate: Spacetime
): string {
const weeksDifference = entryDate
.startOf('week')
.diff(fullyAdjustedCurrentDate.startOf('week'), 'weeks');
if (weeksDifference === 0) {
return 'This Week';
}
if (weeksDifference === 1) {
return '1 week ago';
}
return `${weeksDifference.toString()} weeks ago`;
}
export function formatDate(date: Date, locale: string): string {
const formatter = new Intl.DateTimeFormat(locale);
return formatter.format(date);
}
export function formatDateWithHour(date: Date, locale: string): string {
const formatter = new Intl.DateTimeFormat(locale, {
dateStyle: 'short',
timeStyle: 'short'
});
return formatter.format(date);
}
export function formatDiffFileType(file: DiffFile): string {
const fileExtension = getDisplayExtensionFromPath(file.pathGitRelative);
if (fileExtension === '') {
return 'BINARY';
}
return fileExtension.toLocaleUpperCase();
}
export function formatMonthYear(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
month: 'long',
year: 'numeric'
}).format(date);
}
export function getDisplayExtensionFromPath(filePath: string): string {
const normalizedPath = normalizePath(filePath);
const segments = normalizedPath.split('/');
const fileName = segments.pop() ?? '';
if (fileName === '') {
return '';
}
// Ignore dotfiles
if (fileName.startsWith('.') && !fileName.includes('.', 1)) {
return '';
}
const dotIndex = fileName.lastIndexOf('.');
if (dotIndex === -1 || dotIndex === fileName.length - 1) {
return '';
}
return fileName.slice(Math.max(0, dotIndex + 1)).toLowerCase();
}

177
src/Views/helper.ts Normal file
View file

@ -0,0 +1,177 @@
import type { ItemView } from 'obsidian';
import type { ChangelogEntry } from 'Views/types.svelte.ts';
import { appendChangelogEntries } from 'core/loadingEntries.ts';
import { MarkdownView, TFile } from 'obsidian';
import {
changelogGenerationSettingsUnchanged,
fileChangelogGenerationSettingsUnchanged,
vaultChangelogGenerationSettingsUnchanged
} from 'settings/helper.ts';
import type GitChangelogPlugin from '../main.ts';
import type { DiffFile } from '../types.ts';
import { getNewLeaf } from '../utils.ts';
export async function appendEntries({
abortSignal,
entries,
fileOrVault,
plugin
// RecomputeChangelog
}: {
abortSignal: AbortSignal;
entries: ChangelogEntry[] | undefined;
fileOrVault: 'file' | 'vault';
plugin: GitChangelogPlugin;
// RecomputeChangelog: () => Promise<void>;
}): Promise<void> {
if (
(fileOrVault === 'file'
? fileChangelogGenerationSettingsUnchanged(plugin)
: vaultChangelogGenerationSettingsUnchanged(plugin)) &&
changelogGenerationSettingsUnchanged(plugin)
) {
await appendChangelogEntries({
abortSignal,
fileOrVault,
filePath: fileOrVault === 'file' ? getCurrentFilePath(plugin) : undefined,
plugin,
resetCache: entries === undefined,
upperBoundaryCommit: getUpperBoundaryCommit(entries)
});
}
// : recomputeChangelog());
}
export function changelogFileClick(
event: MouseEvent,
file: DiffFile,
plugin: GitChangelogPlugin,
aReference?: string,
bReference?: string
): void {
event.stopPropagation();
if (file.textDiffStats && aReference && bReference) {
// Show the diff that compares that version to the version from the previous day or if some other interval were specified
showDiff(event, file, plugin, aReference, bReference);
}
}
export function getActiveGitFileFromView(
activeFileView: MarkdownView | null,
plugin: GitChangelogPlugin
): string | undefined {
if (activeFileView?.file) {
const gitPlugin = plugin.getGitPlugin();
return gitPlugin.gitManager.getRelativeRepoPath(
activeFileView.file.path,
true
);
}
}
export function getActiveGitRelativeFile(
plugin: GitChangelogPlugin
): string | undefined {
const activeFileView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
return getActiveGitFileFromView(activeFileView, plugin);
}
export function getCurrentFilePath(
plugin: GitChangelogPlugin
): string | undefined {
return plugin.fileChangelogEntries === undefined ||
plugin.fileChangelogEntries.length === 0
? plugin.cachedActiveGitFile
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.fileChangelogEntries.at(-1)!.pathGitRelative;
}
export function getDisplayPath(path: string): string {
if (path.endsWith('/')) {
return path;
}
return path.split('/').last()?.replace(/\.md$/, '') ?? '';
}
export function getUpperBoundaryCommit(
entries: ChangelogEntry[] | undefined
): string | undefined {
if (entries !== undefined && entries.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return entries.at(-1)!.commitHash;
}
}
export function initialCommitReached({
entries
}: {
entries?: ChangelogEntry[];
}): boolean {
if (entries && (entries.length === 0 || entries.at(-1)?.isInitialCommit())) {
return true;
}
return false;
}
export function isDiffView(view: ItemView | null): boolean {
return (
view?.getViewType() === 'diff-view' ||
view?.getViewType() === 'split-diff-view'
);
}
export function openFile({
event,
file,
plugin,
relativeVaultPath
}: {
event: MouseEvent;
file: DiffFile;
plugin: GitChangelogPlugin;
relativeVaultPath: string;
}): void {
event.stopPropagation();
const obsidianFile =
plugin.app.vault.getAbstractFileByPath(relativeVaultPath);
if (obsidianFile instanceof TFile) {
getNewLeaf(plugin.app, event)
?.openFile(obsidianFile)
.catch((error) => {
plugin.displayError(error);
});
} else if (relativeVaultPath === undefined) {
if (file.fromPathGitRelative) {
plugin.displayNotice(
"Can't open this version of the file because it doesn't exist in the vault anymore."
);
} else {
plugin.displayNotice('This is the initial version. No diff to show.');
}
} else if (obsidianFile === undefined) {
plugin.displayNotice(`Can't open ${file.pathGitRelative}.`);
}
}
// Assumes this function won't be called if you want to show diff of the initial version
export function showDiff(
event: MouseEvent,
file: DiffFile,
plugin: GitChangelogPlugin,
aReference: string,
bReference: string
): void {
plugin.getGitPlugin().tools.openDiff({
aFile: file.fromPathGitRelative ?? file.pathGitRelative,
aRef: aReference,
bFile: file.pathGitRelative,
bRef: bReference,
event
});
}

139
src/Views/types.svelte.ts Normal file
View file

@ -0,0 +1,139 @@
import type { Spacetime } from 'spacetime';
import type { DiffFile, FilesSummary, TextDiffStats } from 'types.ts';
import { DiffFileStatus } from 'types.ts';
export abstract class ChangelogEntry {
public constructor(
public timezoneAdjustedDate: Spacetime,
public commitHash: string // Represents a single commit that's just the latest commit of a certain interval (day,week...).
) {}
public abstract isInitialCommit(): boolean;
}
export class FileChangelogEntry extends ChangelogEntry implements DiffFile {
public fromPathGitRelative?: string;
public pathGitRelative: string;
public status: DiffFileStatus;
public textDiffStats?: TextDiffStats;
public constructor({
commitHash,
fromPathGitRelative,
pathGitRelative,
status,
textDiffStats,
timezoneAdjustedDate
}: {
commitHash: string;
fromPathGitRelative?: string;
pathGitRelative: string;
status: DiffFileStatus;
textDiffStats?: TextDiffStats;
timezoneAdjustedDate: Spacetime;
}) {
super(timezoneAdjustedDate, commitHash);
this.pathGitRelative = pathGitRelative;
this.status = status;
this.fromPathGitRelative = fromPathGitRelative;
this.textDiffStats = textDiffStats;
}
public override isInitialCommit(): boolean {
return this.status === DiffFileStatus.Added;
}
// IsCollapsed?: boolean;
}
export class VaultChangelogEntry extends ChangelogEntry {
public binaryFiles: DiffFile[];
public binaryFilesSummaryCached: FilesSummary;
public isCollapsed = $state<boolean>(true);
public previousDayLastCommitHash?: string; // Empty on the first version
public textFiles: DiffFile[];
public textFilesSummaryCached: FilesSummary;
public constructor({
binaryFiles,
binaryFilesSummaryCached,
commitHash,
previousDayLastCommitHash,
textFiles,
textFilesSummaryCached,
timezoneAdjustedDate
}: {
binaryFiles: DiffFile[];
binaryFilesSummaryCached: FilesSummary;
commitHash: string;
previousDayLastCommitHash?: string;
textFiles: DiffFile[];
textFilesSummaryCached: FilesSummary;
timezoneAdjustedDate: Spacetime;
}) {
super(timezoneAdjustedDate, commitHash);
this.textFiles = textFiles;
this.binaryFiles = binaryFiles;
this.textFilesSummaryCached = textFilesSummaryCached;
this.binaryFilesSummaryCached = binaryFilesSummaryCached;
this.previousDayLastCommitHash = previousDayLastCommitHash;
}
public getChangelogContentAdditions(): number {
let additions = 0;
for (const file of this.getChangelogFiles()) {
if (file.textDiffStats) {
additions += file.textDiffStats.baseStats.additions;
}
}
return additions;
}
public getChangelogContentDeletions(): number {
let deletions = 0;
for (const file of this.getChangelogFiles()) {
if (file.textDiffStats) {
deletions += file.textDiffStats.baseStats.deletions;
}
}
return deletions;
}
public getChangelogContentMoves(): number {
let moves = 0;
for (const file of this.getChangelogFiles()) {
if (file.textDiffStats?.moveStats) {
moves +=
file.textDiffStats.moveStats.internalMoves +
file.textDiffStats.moveStats.outgoingMoves;
}
}
return moves;
}
public getChangelogFiles(): DiffFile[] {
return [...this.textFiles, ...this.binaryFiles];
}
public getChangelogFilesSummary(): FilesSummary {
return {
addedFiles:
this.textFilesSummaryCached.addedFiles +
this.binaryFilesSummaryCached.addedFiles,
deletedFiles:
this.textFilesSummaryCached.deletedFiles +
this.binaryFilesSummaryCached.deletedFiles,
modifiedFiles:
this.textFilesSummaryCached.modifiedFiles +
this.binaryFilesSummaryCached.modifiedFiles,
renamedFiles:
this.textFilesSummaryCached.renamedFiles +
this.binaryFilesSummaryCached.renamedFiles
};
}
public override isInitialCommit(): boolean {
return this.previousDayLastCommitHash === undefined;
}
}

32
src/commands.ts Normal file
View file

@ -0,0 +1,32 @@
import type { GitChangelogPlugin } from 'GitChangelogPlugin.svelte.ts';
import { FILE_CHANGELOG_VIEW_CONFIG } from 'Views/FileChangelog/FileChangelog.ts';
import { VAULT_CHANGELOG_VIEW_CONFIG } from 'Views/VaultChangelog/VaultChangelog.ts';
export function addCommands(plugin: GitChangelogPlugin): void {
const app = plugin.app;
plugin.addCommand({
callback: async () => {
await app.workspace.ensureSideLeaf(
VAULT_CHANGELOG_VIEW_CONFIG.type,
'left',
{ reveal: true }
);
},
id: `open-${VAULT_CHANGELOG_VIEW_CONFIG.type}`,
name: `Open ${VAULT_CHANGELOG_VIEW_CONFIG.name} view`
});
plugin.addCommand({
callback: async () => {
await app.workspace.ensureSideLeaf(
FILE_CHANGELOG_VIEW_CONFIG.type,
'right',
{ reveal: true }
);
},
id: `open-${FILE_CHANGELOG_VIEW_CONFIG.type}`,
name: `Open ${FILE_CHANGELOG_VIEW_CONFIG.name} view`
});
}

19
src/constants.ts Normal file
View file

@ -0,0 +1,19 @@
export const PLUGIN_NAME = 'Git Changelog';
export const MIN_COMPATIBLE_GIT_PLUGIN_VERSION = '2.31.1';
export const MAX_TESTED_GIT_PLUGIN_VERSION = '2.32.0';
// Icons
export const ADDITIONS_ICON = 'list-plus';
export const DELETIONS_ICON = 'list-minus';
export const FILE_ADDED_ICON = 'file-plus';
export const FILE_MODIFIED_ICON = 'file-diff';
export const FILE_DELETED_ICON = 'file-minus';
export const FILE_RENAMED_ICON = 'file-pen';
export const CHANGE_INTERVAL_ICON = 'calendar-clock';
export const TEXT_FILES_SUMMARY_ICON = 'file-type-2';
export const BINARY_FILES_SUMMARY_ICON = 'file-digit';
export const TOGGLE_FILE_TREE_ICON = 'folder-tree';
export const TOGGLE_FILES_SUMMARY_OPTION_ICON = 'list-tree';
export const OPEN_FILE_ICON = 'file-input';
export const PLUS_ICON = 'plus';
export const MINUS_ICON = 'minus';

View file

@ -0,0 +1,60 @@
import type GitChangelogPlugin from 'main.ts';
import type { LogEntry } from 'types.ts';
import { getTimeZone } from 'settings/ui/CustomTimeZone.ts';
import { getRenameDetectionSensitivity } from 'settings/ui/RenameDetectionSensitivitySlider.ts';
import spacetime from 'spacetime';
export async function findFirstCommitBefore({
filePath,
minutes,
plugin
}: {
filePath: string | undefined;
minutes: number;
plugin: GitChangelogPlugin;
}): Promise<LogEntry | undefined> {
const options: Record<string, unknown> = {
...(filePath ? { file: filePath } : {}),
// "--no-patch": null,
// eslint-disable-next-line no-magic-numbers
'--before': `${minutes * 60 - 3} seconds ago`,
'--diff-merges': 'first-parent',
format: {
date: '%cI',
hash: '%H'
},
maxCount: 1,
// Splitter: '\0',
strictDate: true
};
if (filePath) {
options['--name-status'] = null;
// --name-only
options['--follow'] = null;
const renameDetectionSensitivity = getRenameDetectionSensitivity(
plugin.settings.changelogGenerationSettings
);
options['--find-renames'] = `${renameDetectionSensitivity.toString()}%`;
}
const git = await plugin.getGit();
const result = await git.log(options);
const timezone = getTimeZone(
plugin.settings.changelogGenerationSettings,
plugin
);
// If all commits fall inside the interval, or file isn't in the repo
if (result.total === 0) {
return undefined;
}
return result.all.map<LogEntry>((entry) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
filePath: filePath ? entry.diff?.files.first()!.file : undefined,
hash: entry.hash,
timezoneAdjustedDate: spacetime(entry.date).goto(timezone)
}))[0];
}

View file

@ -0,0 +1,129 @@
import type GitChangelogPlugin from 'main.ts';
import type { DiffFile, FilesSummary } from 'types.ts';
import { normalizePath } from 'obsidian';
import { getDiffAlgorithm } from 'settings/ui/DiffAlgorithmOptions.ts';
import { DiffAlgorithm, DiffFileStatus } from 'types.ts';
import { getFileNameFromPath } from 'utils.ts';
import { getDisplayExtensionFromPath } from 'Views/formatters.ts';
export function addFileStatusToSummary(
status: DiffFileStatus,
file: FilesSummary
): void {
if (status === DiffFileStatus.Added) {
file.addedFiles++;
} else if (status === DiffFileStatus.Deleted) {
file.deletedFiles++;
} else if (isFileRenamedOrMoved(status)) {
file.renamedFiles++;
} else {
file.modifiedFiles++;
}
}
export function assignDiffAlgorithm(
arguments_: string[],
plugin: GitChangelogPlugin
): void {
switch (getDiffAlgorithm(plugin.settings.changelogGenerationSettings)) {
case DiffAlgorithm.Default: {
arguments_.push('--diff-algorithm=default');
break;
}
case DiffAlgorithm.Minimal: {
arguments_.push('--diff-algorithm=minimal');
break;
}
default: {
break;
}
}
}
export function calculateFileStatusRenamedOrMoved(
oldPath: string,
newPath: string
): DiffFileStatus {
let status: DiffFileStatus;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
const isMoved = isFileMoved(normalizedOldPath, normalizedNewPath);
const isRenamed = isFileRenamed(normalizedOldPath, normalizedNewPath);
if (isRenamed && isMoved) {
status = DiffFileStatus.RenamedAndMoved;
} else if (isMoved) {
status = DiffFileStatus.Moved;
} else {
status = DiffFileStatus.Renamed;
}
return status;
}
export function calculatePerFileTypeSummaries({
files
}: {
files: DiffFile[];
}): Record<string, FilesSummary> {
const perFileTypeSummaries: Record<string, FilesSummary> = {};
for (const file of files) {
// The header token always contains the added/deleted counts.
const fileType = getDisplayExtensionFromPath(file.pathGitRelative);
if (!perFileTypeSummaries[fileType]) {
perFileTypeSummaries[fileType] = {
addedFiles: 0,
deletedFiles: 0,
modifiedFiles: 0,
renamedFiles: 0
};
}
addFileStatusToSummary(file.status, perFileTypeSummaries[fileType]);
}
return perFileTypeSummaries;
}
export function isFileMoved(
normalizedOldPath: string,
normalizedNewPath: string
): boolean {
const normalizedOldPathLastSlashIndex = normalizedOldPath.lastIndexOf('/');
const normalizedNewPathLastSlashIndex = normalizedNewPath.lastIndexOf('/');
const oldDirectory = normalizedOldPath.slice(
0,
normalizedOldPathLastSlashIndex === -1 ? 0 : normalizedOldPathLastSlashIndex
);
const newDirectory = normalizedNewPath.slice(
0,
normalizedNewPathLastSlashIndex === -1 ? 0 : normalizedNewPathLastSlashIndex
);
return oldDirectory !== newDirectory;
}
export function isFileRenamed(
normalizedOldPath: string,
normalizedNewPath: string
): boolean {
const oldName = getFileNameFromPath({
normalizedFilePath: normalizedOldPath
});
const newName = getFileNameFromPath({
normalizedFilePath: normalizedNewPath
});
return oldName !== newName;
}
export function isFileRenamedOrMoved(file: DiffFileStatus): boolean {
return (
file === DiffFileStatus.Moved ||
file === DiffFileStatus.Renamed ||
file === DiffFileStatus.RenamedAndMoved
);
}

View file

@ -0,0 +1,32 @@
import type GitChangelogPlugin from 'main.ts';
/**
* Doesn't work because of the way SimpleGit instance is set up in Git plugin.
*/
export async function isAncestorOf({
newCommit,
oldCommit,
plugin
}: {
newCommit: string;
oldCommit: string;
plugin: GitChangelogPlugin;
}): Promise<boolean> {
if (oldCommit === newCommit) {
return true;
}
const git = await plugin.getGit();
const result = await git.raw([
'merge-base',
'--is-ancestor',
oldCommit,
newCommit
]);
// Plugin.consoleDebug(result);
if (result === '1') {
return true;
}
return false;
}

View file

@ -0,0 +1,15 @@
import type GitChangelogPlugin from 'main.ts';
export async function runCheckIgnore({
activeGitFile,
plugin
}: {
activeGitFile: string;
plugin: GitChangelogPlugin;
}): Promise<boolean> {
const gitCheckIgnoreResult = await plugin
.getGitPlugin()
.gitManager.git.checkIgnore(activeGitFile);
return !gitCheckIgnoreResult || gitCheckIgnoreResult.length > 0;
}

View file

@ -0,0 +1,91 @@
import type GitChangelogPlugin from 'main.ts';
import type { LogEntry } from 'types.ts';
import {
assignDiffAlgorithm,
calculateFileStatusRenamedOrMoved
} from 'core/gitOperations/helper.ts';
import { AbortError, DiffFileStatus } from 'types.ts';
import { parseContentChange } from 'utils.ts';
import { FileChangelogEntry } from 'Views/types.svelte.ts';
export async function runFileDiff({
abortSignal,
newCommit,
oldCommit,
plugin
}: {
abortSignal: AbortSignal;
newCommit: LogEntry;
oldCommit?: LogEntry;
plugin: GitChangelogPlugin;
}): Promise<FileChangelogEntry> {
let fileStatus: DiffFileStatus;
if (oldCommit === undefined) {
fileStatus = DiffFileStatus.Added;
} else if (oldCommit.filePath === newCommit.filePath) {
fileStatus = DiffFileStatus.Modified;
} else {
fileStatus = calculateFileStatusRenamedOrMoved(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
oldCommit.filePath!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
newCommit.filePath!
);
}
if (oldCommit === undefined) {
const initialFileEntry = new FileChangelogEntry({
commitHash: newCommit.hash,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
pathGitRelative: newCommit.filePath!,
status: fileStatus,
timezoneAdjustedDate: newCommit.timezoneAdjustedDate
});
return initialFileEntry;
}
const numstatArguments = [
'--numstat',
'--color-moved=no',
'-z',
'--no-renames'
// `--exit-code`,
];
assignDiffAlgorithm(numstatArguments, plugin);
numstatArguments.push(
`${oldCommit.hash}:${oldCommit.filePath}`,
`${newCommit.hash}:${newCommit.filePath}`
);
if (abortSignal.aborted) {
throw new AbortError();
}
const git = await plugin.getGit();
const diffNumstatResult = await git.diff(numstatArguments);
const parts = diffNumstatResult.split('\t');
const addedString = parts[0];
const deletedString = parts[1];
// Determine if this is a binary file or submodule.
const isBinary = addedString === '-' && deletedString === '-';
// Parse numeric values for text files.
const textDiffStats = isBinary
? undefined
: parseContentChange({
addedStr: addedString,
deletedStr: deletedString
});
const fileEntry = new FileChangelogEntry({
commitHash: newCommit.hash,
fromPathGitRelative: oldCommit.filePath,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
pathGitRelative: newCommit.filePath!,
status: fileStatus,
textDiffStats,
timezoneAdjustedDate: newCommit.timezoneAdjustedDate
});
return fileEntry;
}

View file

@ -0,0 +1,72 @@
import type GitChangelogPlugin from 'main.ts';
import type { LogEntry } from 'types.ts';
import { getTimeZone } from 'settings/ui/CustomTimeZone.ts';
import { getRenameDetectionSensitivity } from 'settings/ui/RenameDetectionSensitivitySlider.ts';
import spacetime from 'spacetime';
import { AbortError } from 'types.ts';
// Less efficient than running raw?
export async function runLog({
abortSignal,
filePath,
lowerBoundaryCommit,
maxCount,
plugin,
upperBoundaryCommit
}: {
abortSignal: AbortSignal;
filePath: string | undefined;
lowerBoundaryCommit: string | undefined;
maxCount?: number;
plugin: GitChangelogPlugin;
upperBoundaryCommit: string | undefined;
}): Promise<LogEntry[]> {
// This is confusing, and could be accidentally broken in the future
const retrievingNewLogs = lowerBoundaryCommit !== undefined;
const options: Record<string, unknown> = {
...(filePath ? { file: filePath } : {}),
...(maxCount ? { maxCount } : {}),
format: {
date: '%cI',
hash: '%H'
},
// Splitter: '\0',
strictDate: true
// "--no-patch": null,
};
if (filePath) {
// Ensures that the changed files are listed for merge commits as well and the commit is not repeated for each parent. This only lists the changed files for the first parent. (main branch?)
options['--diff-merges'] = 'first-parent';
options['--name-only'] = null;
// --name-only
options['--follow'] = null;
options['--find-renames'] =
`${getRenameDetectionSensitivity(plugin.settings.changelogGenerationSettings)}%`;
}
if (upperBoundaryCommit) {
options[upperBoundaryCommit] = null;
} else if (retrievingNewLogs) {
options['--boundary'] = null;
options[`${lowerBoundaryCommit}..HEAD`] = null;
}
if (abortSignal.aborted) {
throw new AbortError();
}
const git = await plugin.getGit();
const result = await git.log(options);
const timezone = getTimeZone(
plugin.settings.changelogGenerationSettings,
plugin
);
return result.all.map<LogEntry>((entry) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
filePath: filePath ? entry.diff?.files.at(0)!.file : undefined,
hash: entry.hash,
timezoneAdjustedDate: spacetime(entry.date).goto(timezone)
}));
}

View file

@ -0,0 +1,228 @@
/* eslint-disable no-magic-numbers */
import type GitChangelogPlugin from 'main.ts';
import type { DiffFile, FilesSummary, LogEntry, TextDiffFile } from 'types.ts';
import {
addFileStatusToSummary,
assignDiffAlgorithm,
calculateFileStatusRenamedOrMoved
} from 'core/gitOperations/helper.ts';
import { convertGitIgnoreToPathspec } from 'settings/ui/GitDiffIgnore.ts';
import { getRenameLimit } from 'settings/ui/RenameDetectionFileLimit.ts';
import { getRenameDetectionSensitivity } from 'settings/ui/RenameDetectionSensitivitySlider.ts';
import { AbortError, DiffFileStatus } from 'types.ts';
import { insertSorted, parseContentChange } from 'utils.ts';
import { VaultChangelogEntry } from 'Views/types.svelte.ts';
import { runRepoDiffStatus } from './runRepoDiffStatus.ts';
export function compareBinaryFiles(
leftFile: DiffFile,
rightFile: DiffFile
): number {
const statusOrder = {
[DiffFileStatus.Added]: 3,
[DiffFileStatus.Deleted]: 1,
[DiffFileStatus.Modified]: 2,
[DiffFileStatus.Moved]: 5,
[DiffFileStatus.Renamed]: 6,
[DiffFileStatus.RenamedAndMoved]: 4
};
const aStatusOrder = statusOrder[leftFile.status] || 7;
const bStatusOrder = statusOrder[rightFile.status] || 7;
if (aStatusOrder !== bStatusOrder) {
return aStatusOrder - bStatusOrder;
}
return leftFile.pathGitRelative.localeCompare(rightFile.pathGitRelative);
}
export function compareTextFiles(
leftFile: TextDiffFile,
rightFile: TextDiffFile
): number {
const aChanges =
(leftFile.textDiffStats.baseStats.additions || 0) +
(leftFile.textDiffStats.baseStats.deletions || 0);
const bChanges =
(rightFile.textDiffStats.baseStats.additions || 0) +
(rightFile.textDiffStats.baseStats.deletions || 0);
if (bChanges !== aChanges) {
return bChanges - aChanges;
}
return leftFile.pathGitRelative.localeCompare(rightFile.pathGitRelative);
}
export async function runRepoDiff({
abortSignal,
newCommit,
oldCommit,
plugin
}: {
abortSignal: AbortSignal;
newCommit: LogEntry;
oldCommit?: LogEntry;
plugin: GitChangelogPlugin;
}): Promise<null | VaultChangelogEntry> {
if (oldCommit === undefined) {
return new VaultChangelogEntry({
binaryFiles: [],
binaryFilesSummaryCached: {
addedFiles: 0,
deletedFiles: 0,
modifiedFiles: 0,
renamedFiles: 0
},
commitHash: newCommit.hash,
textFiles: [],
textFilesSummaryCached: {
addedFiles: 0,
deletedFiles: 0,
modifiedFiles: 0,
renamedFiles: 0
},
timezoneAdjustedDate: newCommit.timezoneAdjustedDate
});
}
const pathSpec = convertGitIgnoreToPathspec(plugin);
if (newCommit === undefined) {
plugin.consoleDebug('newCommit is undefined');
}
if (abortSignal.aborted) {
throw new AbortError();
}
const statusResult = await runRepoDiffStatus({
newCommit: newCommit.hash,
oldCommit: oldCommit.hash,
pathSpec,
plugin
});
const numstatArguments = [
'--numstat',
`-l${getRenameLimit(plugin.settings.changelogGenerationSettings)}`,
`--find-renames=${getRenameDetectionSensitivity(plugin.settings.changelogGenerationSettings)}%`,
'--color-moved=no',
'--no-rename-empty',
'-z'
];
assignDiffAlgorithm(numstatArguments, plugin);
numstatArguments.push(oldCommit.hash, newCommit.hash);
if (pathSpec.length > 0) {
numstatArguments.push('--', ...pathSpec);
}
if (abortSignal.aborted) {
throw new AbortError();
}
const git = await plugin.getGit();
const diffNumstatResult = await git.diff(numstatArguments);
const records = diffNumstatResult.split('\0').filter((token) => token !== '');
if (records.length === 0) {
return null;
}
const textFilesSummary: FilesSummary = {
addedFiles: 0,
deletedFiles: 0,
modifiedFiles: 0,
renamedFiles: 0
};
const binaryFilesSummary: FilesSummary = {
addedFiles: 0,
deletedFiles: 0,
modifiedFiles: 0,
renamedFiles: 0
};
const textFiles: DiffFile[] = [];
const binaryFiles: DiffFile[] = [];
let index = 0;
while (index < records.length) {
// The header token always contains the added/deleted counts.
const header = records[index++];
const parts = header.split('\t');
const addedString = parts[0];
const deletedString = parts[1];
let filePath: string;
let oldPath: string | undefined = undefined;
if (parts.length === 3 && parts[2] !== '') {
// Simple case: a single file record.
filePath = parts[2];
} else {
// Renamed file case:
// The header ends with an empty field, so we expect two additional tokens:
// First token: the preImage path, second token: the postImage path.
const preImage = records[index++];
const postImage = records[index++];
oldPath = preImage;
filePath = postImage;
}
// Determine if this is a binary file or submodule.
const isBinary = addedString === '-' && deletedString === '-';
// Get the file change status from the "git status" results (defaulting to Modified).
let status: DiffFileStatus;
if (oldCommit === undefined) {
status = DiffFileStatus.Added;
} else if (oldPath) {
if (typeof oldPath !== 'string') {
plugin.consoleDebug('oldPath is not a string!', oldPath);
}
status = calculateFileStatusRenamedOrMoved(oldPath, filePath);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
} else if (statusResult![filePath]) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
status = statusResult![filePath];
} else {
status = DiffFileStatus.Modified;
}
// Update counters based on file type (binary vs text) and status.
if (isBinary) {
addFileStatusToSummary(status, binaryFilesSummary);
} else {
addFileStatusToSummary(status, textFilesSummary);
}
// Parse numeric values for text files.
const textDiffStats = isBinary
? undefined
: parseContentChange({
addedStr: addedString,
deletedStr: deletedString
});
const file: DiffFile = {
fromPathGitRelative: oldPath,
pathGitRelative: filePath,
status,
textDiffStats
};
if (file.textDiffStats) {
insertSorted(textFiles, file, compareTextFiles);
} else {
insertSorted(binaryFiles, file, compareBinaryFiles);
}
}
const dayEntry = new VaultChangelogEntry({
binaryFiles,
binaryFilesSummaryCached: binaryFilesSummary,
commitHash: newCommit.hash,
previousDayLastCommitHash: oldCommit.hash,
textFiles,
textFilesSummaryCached: textFilesSummary,
timezoneAdjustedDate: newCommit.timezoneAdjustedDate
});
return dayEntry;
}

View file

@ -0,0 +1,65 @@
import type GitChangelogPlugin from 'main.ts';
import { DiffFileStatus } from 'types.ts';
/**
* Check the number of changed files and if they are added, modified or deleted. this function is needed because git diff --numstat doesn't say if a file is added or deleted
*/
export async function runRepoDiffStatus({
newCommit,
oldCommit,
pathSpec,
plugin
}: {
newCommit: string;
oldCommit?: string;
pathSpec: string[];
plugin: GitChangelogPlugin;
}): Promise<Record<string, DiffFileStatus> | undefined> {
if (oldCommit === undefined) {
return undefined;
}
const diffStatusArguments = [
oldCommit,
newCommit,
'--name-status',
// Turns off rename detection, even when the configuration file gives the default to run rename detection.
'--no-renames',
'-z'
];
if (pathSpec.length > 0) {
diffStatusArguments.push('--', ...pathSpec);
}
const git = await plugin.getGit();
const diffStatusResult = await git.diff(diffStatusArguments);
const tokens = diffStatusResult
.split('\0')
.filter((token) => token.length > 0);
const changedFilesMap: Record<string, DiffFileStatus> = {};
// Tokens should alternate between the status and the file path.
// eslint-disable-next-line no-magic-numbers
for (let index = 0; index < tokens.length; index += 2) {
const rawStatus = tokens[index];
const filePath = tokens[index + 1];
const statusSymbol = rawStatus.charAt(0);
// BUG:?
switch (statusSymbol) {
case 'A': {
changedFilesMap[filePath] = DiffFileStatus.Added;
break;
}
case 'D': {
changedFilesMap[filePath] = DiffFileStatus.Deleted;
break;
}
// All other types are going to be discarded from this list
default: {
changedFilesMap[filePath] = DiffFileStatus.Modified;
}
}
}
return changedFilesMap;
}

View file

@ -0,0 +1,44 @@
/* eslint-disable unicorn/prevent-abbreviations */
import type GitChangelogPlugin from 'main.ts';
import type { LogEntry, TextDiffBaseStats } from 'types.ts';
import { assignDiffAlgorithm } from 'core/gitOperations/helper.ts';
import { parseContentChange } from 'utils.ts';
/**
* Used for status bar stats.
*/
export async function runWorkingDirFileDiff({
oldCommit,
plugin
}: {
oldCommit: LogEntry;
plugin: GitChangelogPlugin;
}): Promise<TextDiffBaseStats | undefined> {
const numstatArguments = [
'--numstat',
'--color-moved=no',
'-z',
'--no-renames',
oldCommit.hash,
`${oldCommit.filePath}`
];
assignDiffAlgorithm(numstatArguments, plugin);
const git = await plugin.getGit();
const diffNumstatResult = await git.diff(numstatArguments);
const parts = diffNumstatResult.split('\t');
const addedString = parts[0];
const deletedString = parts[1];
// Determine if this is a binary file or submodule.
const isBinary = addedString === '-' && deletedString === '-';
// Parse numeric values for text files.
const textDiffStats = isBinary
? undefined
: parseContentChange({ addedStr: addedString, deletedStr: deletedString });
return textDiffStats?.baseStats;
}

153
src/core/helper.ts Normal file
View file

@ -0,0 +1,153 @@
import type GitChangelogPlugin from 'main.ts';
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import type { Spacetime } from 'spacetime';
import type { ChangelogInterval, FilesSummary, LogEntry } from 'types.ts';
import type {
FileChangelogEntry,
VaultChangelogEntry
} from 'Views/types.svelte.ts';
import { runFileDiff } from 'core/gitOperations/runFileDiff.ts';
import { runRepoDiff } from 'core/gitOperations/runRepoDiff.ts';
import { getDayStartTime } from 'settings/ui/DayStartTime.ts';
import { getChangelogInterval } from 'settings/validation/changelogInterval.ts';
import { applyDayStartTimeSetting } from 'timeUtils.ts';
export const GIT_MAX_CONCURRENT_PROCESSES = 6;
export function addStatsToSummary(
mainSummary: FilesSummary,
stats: FilesSummary
): void {
mainSummary.addedFiles += stats.addedFiles;
mainSummary.deletedFiles += stats.deletedFiles;
mainSummary.renamedFiles += stats.renamedFiles;
mainSummary.modifiedFiles += stats.modifiedFiles;
}
export async function appendToFileChangelogEntries({
abortSignal,
currentCommit,
entries,
plugin,
previousCommit
}: {
abortSignal: AbortSignal;
currentCommit: LogEntry;
entries: FileChangelogEntry[];
plugin: GitChangelogPlugin;
previousCommit?: LogEntry;
}): Promise<void> {
const entry = await runFileDiff({
abortSignal,
newCommit: currentCommit,
oldCommit: previousCommit,
plugin
});
entries.push(entry);
}
export async function appendToVaultChangelogEntries({
abortSignal,
currentCommit,
entries,
plugin,
previousCommit
}: {
abortSignal: AbortSignal;
currentCommit: LogEntry;
entries: VaultChangelogEntry[];
plugin: GitChangelogPlugin;
previousCommit?: LogEntry;
}): Promise<void> {
const entry = await runRepoDiff({
abortSignal,
newCommit: currentCommit,
oldCommit: previousCommit,
plugin
});
// Generate a new version only if the Git diff shows changes. Versions with no changes can occur frequently if a restrictive additional .gitignore is specified in the plugin settings.
if (entry) {
entries.push(entry);
}
}
/**
* Basic checking to avoid creating multiple version entries for the same date or interval. If a date already exists in newer entries, just ignore this anomaly (happens if Git history isn't linear).
*/
export function dateAlreadySeen({
fullyAdjustedNewDate,
interval,
previouslySeenFullyAdjustedDates
}: {
fullyAdjustedNewDate: Spacetime;
interval: ChangelogInterval;
previouslySeenFullyAdjustedDates: Set<Spacetime>;
}): boolean {
// We can group the hourly versions with other versions since this date won't be displayed in the UI. We're only comparing if they're the same, so it's fine to subtract the same amount from both entries
for (const date of previouslySeenFullyAdjustedDates) {
if (date.isSame(fullyAdjustedNewDate, interval)) {
return true;
}
}
return false;
}
export function extractLastCommitsForInterval({
changelogGenerationSettings,
interval,
previouslySeenFullyAdjustedDates,
timezoneAdjustedLogs
}: {
changelogGenerationSettings: ChangelogGenerationSettings;
interval: ChangelogInterval;
previouslySeenFullyAdjustedDates?: Set<Spacetime>;
timezoneAdjustedLogs: LogEntry[];
}): LogEntry[] {
const lastCommitsInEachInterval: LogEntry[] = [];
const fullyAdjustedSeenDates =
previouslySeenFullyAdjustedDates ?? new Set<Spacetime>();
for (const log of timezoneAdjustedLogs) {
const fullyAdjustedLogDate = applyDayStartTimeSetting({
dayStartTime: getDayStartTime(changelogGenerationSettings),
timezoneAdjustedDate: log.timezoneAdjustedDate
});
if (
!dateAlreadySeen({
fullyAdjustedNewDate: fullyAdjustedLogDate,
interval,
previouslySeenFullyAdjustedDates: fullyAdjustedSeenDates
})
) {
lastCommitsInEachInterval.push(log);
fullyAdjustedSeenDates.add(fullyAdjustedLogDate);
}
}
return lastCommitsInEachInterval;
}
/**
* This allows us to check if relevant settings have changed and only then recompute changelogs, instead of recomputing after every single settings change.
*/
export function recordUsedSettings(
plugin: GitChangelogPlugin,
fileOrVault: 'file' | 'vault'
): void {
plugin.settingsOfComputedCache = structuredClone(
plugin.settings.changelogGenerationSettings
);
if (fileOrVault === 'vault') {
plugin.vaultChangelogCacheInterval = getChangelogInterval(
plugin,
fileOrVault
);
} else if (fileOrVault === 'file') {
plugin.fileChangelogCacheInterval = getChangelogInterval(
plugin,
fileOrVault
);
}
}

306
src/core/loadingEntries.ts Normal file
View file

@ -0,0 +1,306 @@
/* eslint-disable no-magic-numbers */
import type GitChangelogPlugin from 'main.ts';
import type { Spacetime } from 'spacetime';
import type { LogEntry } from 'types.ts';
import type {
ChangelogEntry,
FileChangelogEntry,
VaultChangelogEntry
} from 'Views/types.svelte.ts';
import { runLog } from 'core/gitOperations/runLog.ts';
import {
appendToFileChangelogEntries,
appendToVaultChangelogEntries,
extractLastCommitsForInterval,
recordUsedSettings
} from 'core/helper.ts';
import { getChangelogInterval } from 'settings/validation/changelogInterval.ts';
import { AbortError, ChangelogInterval } from 'types.ts';
export async function appendChangelogEntries({
abortSignal,
fileOrVault,
filePath,
plugin,
resetCache,
upperBoundaryCommit
}: {
abortSignal: AbortSignal;
fileOrVault: 'file' | 'vault';
filePath: string | undefined;
plugin: GitChangelogPlugin;
resetCache: boolean;
upperBoundaryCommit: string | undefined;
}): Promise<void> {
// Either schedule it in a queue or run it directly. These both functions needs to be wrapped inside a promise and passed into the queue.
const newEntries = await getNextChangelogEntries({
abortSignal,
fileOrVault,
filePath,
plugin,
resetCache,
upperBoundaryCommit
});
recordUsedSettings(plugin, fileOrVault);
appendToExistingEntries({
fileOrVault,
loadedFileEntries:
fileOrVault === 'file' ? (newEntries as FileChangelogEntry[]) : undefined,
loadedVaultEntries:
fileOrVault === 'vault'
? (newEntries as VaultChangelogEntry[])
: undefined,
plugin,
resetCache
});
}
// eslint-disable-next-line complexity
export async function getNextChangelogEntries({
abortSignal,
fileOrVault,
filePath,
plugin,
resetCache,
upperBoundaryCommit
}: {
abortSignal: AbortSignal;
fileOrVault: 'file' | 'vault';
filePath: string | undefined;
plugin: GitChangelogPlugin;
resetCache: boolean;
upperBoundaryCommit: string | undefined;
}): Promise<ChangelogEntry[]> {
// Without this check, appendChangelogEntries for vault changelog could be accidentally triggered if filePath is undefined
if (fileOrVault === 'file' && filePath === undefined) {
throw new Error(
'filePath is required when generating file changelog entries.'
);
}
const CHANGELOG_LOAD_AMOUNT_BASE_MULTIPLIER = 35;
const CHANGELOG_LOAD_AMOUNT_VERSIONS = 10;
let reachedInitialCommit = false;
// Try to calculate the optimal max-count number based on varying circumstances.
// Ideally we get just enough logs to fill one batch when scrolling without discard any extra logs or going into more than 1 while loop iteration.
const initialLoadMultiplier = resetCache ? 2 : 1;
const vaultWideMultiplier = fileOrVault === 'file' ? 1 : 6;
const fileViewVersionsMultiplier = fileOrVault === 'file' ? 2.4 : 1;
const interval = getChangelogInterval(plugin, fileOrVault);
const intervalMultiplier = getIntervalMaxCountMultiplier(interval);
const logMaxCount =
CHANGELOG_LOAD_AMOUNT_BASE_MULTIPLIER *
initialLoadMultiplier *
vaultWideMultiplier *
intervalMultiplier;
const minVersionsToGet = Math.ceil(
initialLoadMultiplier *
fileViewVersionsMultiplier *
CHANGELOG_LOAD_AMOUNT_VERSIONS
);
const maxVersionsToGet = Math.ceil(
3 * fileViewVersionsMultiplier * CHANGELOG_LOAD_AMOUNT_VERSIONS
);
plugin.consoleDebug(
'appendChangelogEntries minVersionsToGet',
minVersionsToGet
);
const fullyAdjustedSeenDates = new Set<Spacetime>();
let startingCommit = upperBoundaryCommit; // Inclusive
let startingFilePath: string | undefined = filePath;
const lastCommitsInEachVersion: LogEntry[] = [];
const loadedVaultEntries: VaultChangelogEntry[] = [];
const loadedFileEntries: FileChangelogEntry[] = [];
let upperBoundaryVersionRemoved = false;
// This needs to be cached so that if the original reference in the changelogTaskManager gets reassigned because the queue was cleared, this still points to the old signal.
let logCycles = 0;
while (
lastCommitsInEachVersion.length +
loadedVaultEntries.length +
loadedFileEntries.length <
minVersionsToGet &&
!reachedInitialCommit
) {
logCycles++;
const timezoneAdjustedLogs = await runLog({
abortSignal,
filePath: startingFilePath,
lowerBoundaryCommit: undefined,
maxCount: logMaxCount,
plugin,
upperBoundaryCommit: startingCommit
});
// All we need from a version is its latest commit, not all commits included in that interval
const extractedVersions = extractLastCommitsForInterval({
changelogGenerationSettings: plugin.settings.changelogGenerationSettings,
interval,
previouslySeenFullyAdjustedDates: fullyAdjustedSeenDates,
timezoneAdjustedLogs
});
if (timezoneAdjustedLogs.length < logMaxCount) {
reachedInitialCommit = true;
}
// If getting file changelog versions and need to loop many times, we need to track the file path across renames so that we can follow the target file across its whole history.
startingFilePath = timezoneAdjustedLogs.at(-1)?.filePath;
startingCommit = timezoneAdjustedLogs.at(-1)?.hash;
lastCommitsInEachVersion.push(...extractedVersions);
// Remove the first version if upper boundary commit was specified (to avoid duplicates, because the first version includes the upper boundary commit)
if (
upperBoundaryCommit !== undefined &&
lastCommitsInEachVersion.length > 0 &&
!upperBoundaryVersionRemoved
) {
lastCommitsInEachVersion.shift();
upperBoundaryVersionRemoved = true;
}
plugin.consoleDebug(
'Amount of versions retrieved from Git log:',
lastCommitsInEachVersion.length
);
// Process all versions except the last one. Uses the last version only for comparison and doesn't calculate stats for that version because it has no previous version to compare against (in this loop iteration at least)
while (
lastCommitsInEachVersion.length > 1 &&
loadedFileEntries.length + loadedVaultEntries.length < maxVersionsToGet
) {
const currentCommit = lastCommitsInEachVersion[0];
const previousCommit = lastCommitsInEachVersion[1];
await (fileOrVault === 'file'
? appendToFileChangelogEntries({
abortSignal,
currentCommit,
entries: loadedFileEntries,
plugin,
previousCommit
})
: appendToVaultChangelogEntries({
abortSignal,
currentCommit,
entries: loadedVaultEntries,
plugin,
previousCommit
}));
lastCommitsInEachVersion.shift();
}
}
plugin.consoleDebug(
'Appending log cycles to get sufficient versions:',
logCycles
);
// After the while loop ends, there should always be one entry left in the lastCommitsInEachVersion array.
// We do additional logic if that entry is the initial version.
const nextVersionIsInitialVersion =
reachedInitialCommit &&
// Only append the initial version if we already loaded everything after it, and the initial version is the only one that's left.
lastCommitsInEachVersion.length === 1;
// If initial version reached, then just return an empty version entry for now (TODO: Implement comparing to empty state)
if (nextVersionIsInitialVersion) {
const lastCommit = lastCommitsInEachVersion[0];
await (fileOrVault === 'file'
? appendToFileChangelogEntries({
abortSignal,
currentCommit: lastCommit,
entries: loadedFileEntries,
plugin
})
: appendToVaultChangelogEntries({
abortSignal,
currentCommit: lastCommit,
entries: loadedVaultEntries,
plugin
}));
}
// Final check to see if we still want these results.
if (abortSignal.aborted) {
throw new AbortError();
}
return fileOrVault === 'file' ? loadedFileEntries : loadedVaultEntries;
}
export function getIntervalMaxCountMultiplier(
interval: ChangelogInterval
): 1 | 200 | 56 | 9 {
switch (interval) {
case ChangelogInterval.Daily: {
return 9;
}
case ChangelogInterval.Hourly: {
return 1;
}
case ChangelogInterval.Monthly: {
return 200;
}
case ChangelogInterval.Weekly: {
return 56;
}
default: {
return 1;
}
}
}
function appendToExistingEntries({
fileOrVault,
loadedFileEntries,
loadedVaultEntries,
plugin,
resetCache
}: {
fileOrVault: 'file' | 'vault';
loadedFileEntries: FileChangelogEntry[] | undefined;
loadedVaultEntries: undefined | VaultChangelogEntry[];
plugin: GitChangelogPlugin;
resetCache: boolean;
}): void {
if (resetCache) {
if (fileOrVault === 'file') {
plugin.fileChangelogEntries = loadedFileEntries;
} else {
let firstVersionCollapsed: boolean | undefined;
// Copy over previous first version's collapsed state in the vault changelog if we are changing intervals...
if (
plugin.vaultChangelogEntries !== undefined &&
plugin.vaultChangelogEntries.length > 0
) {
firstVersionCollapsed = plugin.vaultChangelogEntries[0].isCollapsed;
}
plugin.vaultChangelogEntries = loadedVaultEntries;
// ...or expand the first version if this is the initial load
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (plugin.vaultChangelogEntries!.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.vaultChangelogEntries![0].isCollapsed =
firstVersionCollapsed ?? false;
}
}
}
// If we are not resetting the cache, append the new versions to the existing ones.
else if (fileOrVault === 'file') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.fileChangelogEntries!.push(...loadedFileEntries!);
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.vaultChangelogEntries!.push(...loadedVaultEntries!);
}
}

283
src/core/updatingEntries.ts Normal file
View file

@ -0,0 +1,283 @@
import type GitChangelogPlugin from 'main.ts';
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import type { Spacetime } from 'spacetime';
import type { ChangelogInterval } from 'types.ts';
import type {
ChangelogEntry,
FileChangelogEntry,
VaultChangelogEntry
} from 'Views/types.svelte.ts';
import { runLog } from 'core/gitOperations/runLog.ts';
import { getDayStartTime } from 'settings/ui/DayStartTime.ts';
import { getChangelogInterval } from 'settings/validation/changelogInterval.ts';
import { applyDayStartTimeSetting } from 'timeUtils.ts';
import { AbortError } from 'types.ts';
import {
appendToFileChangelogEntries,
appendToVaultChangelogEntries,
extractLastCommitsForInterval,
recordUsedSettings
} from './helper.ts';
export async function updateChangelogEntries({
abortSignal,
fileOrVault,
filePath,
plugin
}: {
abortSignal: AbortSignal;
fileOrVault: 'file' | 'vault';
filePath?: string;
plugin: GitChangelogPlugin;
}): Promise<void> {
const newEntries = await getLatestChangelogEntries({
abortSignal,
fileOrVault,
filePath,
plugin
});
recordUsedSettings(plugin, fileOrVault);
prependToExistingEntries({
fileOrVault,
newEntries,
plugin
});
}
export function changelogCacheHasNoCompleteVersion({
entries
}: {
entries?: ChangelogEntry[];
}): boolean {
if (
entries === undefined ||
entries.length === 0 ||
entries[0].isInitialCommit()
) {
return true;
}
return false;
}
/**
* Fetches and updates the cached changelog with any missing new entries. In practice, this usually just involves overwriting the latest cached version entry with updated data.
*/
export async function getLatestChangelogEntries({
abortSignal,
fileOrVault,
filePath,
plugin
}: {
abortSignal: AbortSignal;
fileOrVault: 'file' | 'vault';
filePath?: string;
plugin: GitChangelogPlugin;
}): Promise<ChangelogEntry[]> {
// Otherwise updateChangelogEntries for vault changelog would be accidentally triggered if filePath was left undefined
if (fileOrVault === 'file' && filePath === undefined) {
throw new Error(
'filePath is required when generating file changelog entries.'
);
}
/**
* If this is undefined then the cached changelog is empty or the only version in it is the initial version.
*/
const latestNotInitialCachedVersionHash = getLatestNotInitialCachedVersion({
filePath,
plugin
});
// Gets all commits newer (>=) than the commit of the latest cached version.
const timezoneAdjustedLogs = await runLog({
abortSignal,
filePath,
lowerBoundaryCommit: latestNotInitialCachedVersionHash,
maxCount: undefined,
plugin,
upperBoundaryCommit: undefined
});
const extractedVersions = extractLastCommitsForInterval({
changelogGenerationSettings: plugin.settings.changelogGenerationSettings,
interval: getChangelogInterval(plugin, fileOrVault),
timezoneAdjustedLogs
});
// Always recalculate the latest version in cached changelog (because it likely has outdated stats), but only if the latest cached version isn't also the initial version.
if (latestNotInitialCachedVersionHash) {
extractedVersions.push({
filePath: filePath
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.fileChangelogEntries![1].pathGitRelative
: undefined,
hash: filePath
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.fileChangelogEntries![1].commitHash
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.vaultChangelogEntries![1].commitHash,
timezoneAdjustedDate: filePath
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.fileChangelogEntries![1].timezoneAdjustedDate
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
plugin.vaultChangelogEntries![1].timezoneAdjustedDate
});
}
const createdVaultEntries: VaultChangelogEntry[] = [];
const createdFileEntries: FileChangelogEntry[] = [];
for (let index = 0; index < extractedVersions.length - 1; index++) {
await (filePath
? appendToFileChangelogEntries({
abortSignal,
currentCommit: extractedVersions[index],
entries: createdFileEntries,
plugin,
previousCommit: extractedVersions[index + 1]
})
: appendToVaultChangelogEntries({
abortSignal,
currentCommit: extractedVersions[index],
entries: createdVaultEntries,
plugin,
previousCommit: extractedVersions[index + 1]
}));
}
// If initial version was reached, append it as an empty version.
if (
extractedVersions.length > 0 &&
latestNotInitialCachedVersionHash === undefined
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const lastCommit = extractedVersions.at(-1)!;
await (filePath
? appendToFileChangelogEntries({
abortSignal,
currentCommit: lastCommit,
entries: createdFileEntries,
plugin
})
: appendToVaultChangelogEntries({
abortSignal,
currentCommit: lastCommit,
entries: createdVaultEntries,
plugin
}));
}
if (abortSignal.aborted) {
throw new AbortError();
}
return fileOrVault === 'file' ? createdFileEntries : createdVaultEntries;
}
export function getLatestNotInitialCachedVersion({
filePath,
plugin
}: {
filePath: string | undefined;
plugin: GitChangelogPlugin;
}): string | undefined {
const entries = filePath
? plugin.fileChangelogEntries
: plugin.vaultChangelogEntries;
if (changelogCacheHasNoCompleteVersion({ entries })) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return entries![0].commitHash;
}
export function isFullyAdjustedIntervalOlder({
changelogGenerationSettings,
interval,
timezoneAdjustedNewDate,
timezoneAdjustedOldDate
}: {
changelogGenerationSettings: ChangelogGenerationSettings;
interval: ChangelogInterval;
timezoneAdjustedNewDate: Spacetime;
timezoneAdjustedOldDate: Spacetime;
}): boolean {
return applyDayStartTimeSetting({
dayStartTime: getDayStartTime(changelogGenerationSettings),
timezoneAdjustedDate: timezoneAdjustedOldDate
})
.startOf(interval)
.isBefore(
applyDayStartTimeSetting({
dayStartTime: getDayStartTime(changelogGenerationSettings),
timezoneAdjustedDate: timezoneAdjustedNewDate
}).startOf(interval)
);
}
/**
* Updates the cached changelog with missing new entries. In practice, most of the time it just overwrites the latest cached version entry with newer data.
*/
export function prependToExistingEntries({
fileOrVault,
newEntries,
plugin
}: {
fileOrVault: 'file' | 'vault';
newEntries: ChangelogEntry[];
plugin: GitChangelogPlugin;
}): void {
const cachedEntries: ChangelogEntry[] | undefined =
fileOrVault === 'file'
? plugin.fileChangelogEntries
: plugin.vaultChangelogEntries;
if (cachedEntries === undefined) {
if (fileOrVault === 'file') {
plugin.fileChangelogEntries = newEntries as FileChangelogEntry[];
} else {
plugin.vaultChangelogEntries = newEntries as VaultChangelogEntry[];
}
return;
}
if (cachedEntries.length === 0) {
cachedEntries.push(...newEntries);
return;
}
// It doesn't assign undefined to the cache, as the empty result may also indicate that no new entries are available and the cached changelog is already up to date. Not designed to handle cases where the repo or file history is completely empty with no changes to detect.
if (newEntries.length > 0) {
// Find index where cachedEntries should start
let firstOldEntryIndex = cachedEntries.length;
for (const [index, cachedEntry] of cachedEntries.entries()) {
if (
isFullyAdjustedIntervalOlder({
changelogGenerationSettings:
plugin.settings.changelogGenerationSettings,
interval: getChangelogInterval(plugin, fileOrVault),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
timezoneAdjustedNewDate: newEntries.at(-1)!.timezoneAdjustedDate,
timezoneAdjustedOldDate: cachedEntry.timezoneAdjustedDate
})
) {
firstOldEntryIndex = index;
break;
}
}
// If updating the latest incomplete version, keep the isCollapsed state from current view. Unnecessary loop?
if (fileOrVault === 'vault') {
const vaultChangelogNewEntries = newEntries as VaultChangelogEntry[];
const vaultChangelogCachedEntries =
cachedEntries as VaultChangelogEntry[];
for (let index = 0; index < firstOldEntryIndex; index++) {
vaultChangelogNewEntries[
vaultChangelogNewEntries.length - firstOldEntryIndex + index
].isCollapsed = vaultChangelogCachedEntries[index].isCollapsed;
}
}
cachedEntries.splice(0, firstOldEntryIndex, ...newEntries);
}
}

86
src/gitPluginTypes.ts Normal file
View file

@ -0,0 +1,86 @@
import type { Plugin } from 'obsidian';
import type { SimpleGit } from 'simple-git';
// For clearer compatibility:
// Import { type SimpleGit as SimpleGitType } from "simple-git";
export interface GitManager {
getRelativeRepoPath(filePath: string, doConversion: boolean): string;
getRelativeVaultPath(path: string): string;
git: SimpleGit;
}
export interface ObsidianGitPlugin extends Plugin {
gitManager: GitManager;
isAllInitialized(): Promise<boolean>;
settings: ObsidianGitSettings;
tools: Tools;
}
export interface ObsidianGitSettings {
autoSaveInterval: number;
}
export interface Tools {
openDiff({
aFile,
aRef,
bFile,
bRef,
event
}: {
aFile: string;
aRef: string;
bFile?: string;
bRef?: string;
event?: MouseEvent;
}): void;
}
// Interface DiffViewState {
// AFile: string;
// ARef?: string;
// BFile: string;
// BRef?: string;
// }
declare module 'obsidian' {
interface App {
plugins: {
getPlugin(id: string): Plugin | undefined;
plugins: {
'obsidian-git': ObsidianGitPlugin;
};
};
}
interface Workspace {
on(
name: 'obsidian-git:head-change',
callback: () => void,
context?: unknown
): EventRef;
on(
name: 'obsidian-git:menu',
callback: (
menu: Menu,
path: string,
source: string,
leaf?: WorkspaceLeaf
) => unknown,
context?: unknown
): EventRef;
trigger(name: string, ...data: unknown[]): void;
trigger(
name: 'obsidian-git:menu',
menu: Menu,
path: string,
source: string,
leaf?: WorkspaceLeaf
): void;
}
}

6
src/main.ts Normal file
View file

@ -0,0 +1,6 @@
import './styles/main.scss';
/* eslint-disable unicorn/prefer-export-from */
import { GitChangelogPlugin } from './GitChangelogPlugin.svelte.ts';
// eslint-disable-next-line import-x/no-default-export
export default GitChangelogPlugin;

View file

@ -0,0 +1,15 @@
import { ButtonComponent } from 'obsidian';
export class ResetButton extends ButtonComponent {
public constructor(protected contentElement: HTMLElement) {
super(contentElement);
this.setTooltip('Restore default');
this.setIcon('rotate-ccw');
this.render();
}
private render(): void {
this.buttonEl.classList.add('clickable-icon');
this.buttonEl.classList.add('extra-setting-button');
}
}

View file

@ -0,0 +1,93 @@
import type GitChangelogPlugin from 'main.ts';
import type { TextComponent } from 'obsidian';
import type {
ChangelogGenerationSettings,
IGitChangelogSettings
} from 'settings/settings.ts';
import type { GitChangelogSettingsTab } from 'settings/settingsTab.ts';
import { moment } from 'obsidian';
import { NumberComponent } from 'obsidian-dev-utils/obsidian/Components/NumberComponent';
import { TimeComponent } from 'obsidian-dev-utils/obsidian/Components/TimeComponent';
import { SettingEx } from 'obsidian-dev-utils/obsidian/SettingEx';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
export abstract class GitChangelogSetting {
protected containerEl: HTMLElement;
protected disabled: boolean;
protected plugin: GitChangelogPlugin;
protected settingTab?: GitChangelogSettingsTab;
public constructor({
containerEl,
disabled = false,
plugin,
settingTab
}: {
containerEl: HTMLElement;
disabled?: boolean;
plugin: GitChangelogPlugin;
settingTab?: GitChangelogSettingsTab;
}) {
this.plugin = plugin;
this.containerEl = containerEl;
this.disabled = disabled;
this.settingTab = settingTab;
}
public abstract display(): void;
protected createSetting(): SettingEx {
const setting = new SettingEx(this.containerEl);
if (this.disabled) {
setting.setDisabled(true);
setting.setClass('git-changelog-disabled');
}
return setting;
}
/**
* It delays the update of the associated ui state of the conditional setting enabled or disabled state, so that the toggle animation can end its animation smoother.
*/
// eslint-disable-next-line no-magic-numbers
protected refreshDisplaySmooth(timeout = 80): void {
if (this.settingTab) {
setTimeout(() => this.settingTab?.display(), timeout);
}
}
protected setValueIfNonDefaultSetting({
diffSettingsProperty,
settingsProperty,
text
}: {
diffSettingsProperty?: keyof ChangelogGenerationSettings;
settingsProperty: keyof IGitChangelogSettings;
text: NumberComponent | TextComponent | TimeComponent;
}): void {
const storedValue = diffSettingsProperty
? (this.plugin.settings[settingsProperty] as ChangelogGenerationSettings)[
diffSettingsProperty
]
: this.plugin.settings[settingsProperty];
const defaultValue = diffSettingsProperty
? (DEFAULT_SETTINGS[settingsProperty] as ChangelogGenerationSettings)[
diffSettingsProperty
]
: DEFAULT_SETTINGS[settingsProperty];
if (defaultValue !== storedValue) {
if (text instanceof NumberComponent) {
text.setValue(Number(storedValue));
} else if (text instanceof TimeComponent) {
text.setValue(moment.duration({ minutes: Number(storedValue) }));
} else {
text.setValue(
typeof storedValue === 'object'
? JSON.stringify(storedValue)
: String(storedValue)
);
}
}
}
}

View file

@ -0,0 +1,36 @@
import type { App } from 'obsidian';
import { AbstractInputSuggest } from 'obsidian';
import { TIME_ZONES_LIST } from 'settings/settings.ts';
export class TimeZoneSuggest extends AbstractInputSuggest<string> {
private inputEl: HTMLInputElement;
public constructor(app: App, inputElement: HTMLInputElement) {
super(app, inputElement);
this.inputEl = inputElement;
}
public getSuggestions(inputString: string): string[] {
const lowerCaseInputString = inputString.toLowerCase();
const timezones: string[] = [];
for (const timezone of TIME_ZONES_LIST) {
if (timezone.toLowerCase().contains(lowerCaseInputString)) {
timezones.push(timezone);
}
}
return timezones;
}
public renderSuggestion(timezone: string, element: HTMLElement): void {
element.setText(timezone);
}
public override selectSuggestion(timezone: string): void {
this.inputEl.value = timezone;
this.inputEl.trigger('input');
this.close();
}
}

57
src/settings/helper.ts Normal file
View file

@ -0,0 +1,57 @@
import type GitChangelogPlugin from 'main.ts';
import { deepEqual } from 'obsidian-dev-utils/Object';
import {
systemTimeZoneUnchanged,
validateCustomTimeZone
} from 'settings/ui/CustomTimeZone.ts';
import { getStatusBarAlternateInterval } from 'settings/ui/StatusBarInterval.ts';
import { getChangelogInterval } from 'settings/validation/changelogInterval.ts';
export function changelogGenerationSettingsUnchanged(
plugin: GitChangelogPlugin
): boolean {
const oldSettings = plugin.settingsOfComputedCache;
const newSettings = plugin.settings.changelogGenerationSettings;
if (!oldSettings) {
return false;
}
// IsAncestor run
if (
!validateCustomTimeZone(newSettings.timezone) &&
!systemTimeZoneUnchanged(plugin)
) {
return false;
}
if (deepEqual(oldSettings, newSettings)) {
return true;
}
return false;
}
export function fileChangelogGenerationSettingsUnchanged(
plugin: GitChangelogPlugin
): boolean {
return (
getChangelogInterval(plugin, 'file') === plugin.fileChangelogCacheInterval
);
}
export function statusBarSettingsUnchanged(
plugin: GitChangelogPlugin
): boolean {
return (
getStatusBarAlternateInterval(plugin) === plugin.statusBarCachedTimeframe
);
}
export function vaultChangelogGenerationSettingsUnchanged(
plugin: GitChangelogPlugin
): boolean {
return (
getChangelogInterval(plugin, 'vault') === plugin.vaultChangelogCacheInterval
);
}

134
src/settings/settings.ts Normal file
View file

@ -0,0 +1,134 @@
/* eslint-disable no-magic-numbers */
import { PluginSettingsBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsBase';
import spacetime from 'spacetime';
import {
ChangelogInterval,
DiffAlgorithm,
DiffMeasurementUnit,
FileExplorerStats,
FilesSummariesDisplayMode
} from 'types.ts';
// Constants
export const TIME_ZONES_LIST = new Set(Object.keys(spacetime().timezones));
export const MAX_SUPPORTED_INTERVAL = 99_999; // ~69 days
export const AUTO_DETECT_TIMEZONE_PLACEHOLDER = 'Auto-detect';
/**
* Each change in these settings triggers a recalculation of the changelogs statistics.
*/
export interface ChangelogGenerationSettings {
// Time settings
dayStartTime: number;
// Diff settings
detectMovedContent: boolean;
diffAlgorithm: DiffAlgorithm;
gitDiffIgnore: string;
measurementUnit: DiffMeasurementUnit;
renameDetectionSensitivity: number;
renameLimit: string;
timezone: string;
}
export interface IGitChangelogSettings {
autoCommitDisabledWarningDismissed: boolean;
changelogGenerationSettings: ChangelogGenerationSettings;
contentDeletionsAndMovesWarningThreshold: string;
dedicatedFileTypeSummaries: string[];
fileChangelogInterval: ChangelogInterval;
fileExplorerInterval: string;
fileExplorerStats: FileExplorerStats;
filesChangesWarningThreshold: string;
fileSummariesDisplayMode: FilesSummariesDisplayMode;
locale: string;
notifyOnContentDeletionsAndMovesThresholdReached: boolean;
notifyOnFilesChangesThresholdReached: boolean;
statusBarInterval: string;
statusBarStats: boolean;
vaultChangelogInterval: ChangelogInterval;
}
export class GitChangelogPluginSettings extends PluginSettingsBase {
// State
public autoCommitDisabledWarningDismissed: boolean =
DEFAULT_SETTINGS.autoCommitDisabledWarningDismissed;
public changelogGenerationSettings: ChangelogGenerationSettings =
DEFAULT_CHANGELOG_GENERATION_SETTINGS;
/**
* The number refers to either words or lines depending on what the changelog is set up to count
*/
public contentDeletionsAndMovesWarningThreshold: string =
DEFAULT_SETTINGS.contentDeletionsAndMovesWarningThreshold;
// ShowFilesSummaryCountOptions[]; //Set<ShowFilesSummaryCountOptions>;
// VaultChangelogFilesVisibility: VaultChangelogFilesVisibility;
// NotifyOnLargeCommitAdditions: boolean;
// NotifyOnLargeCommitAdditionsWarningThreshold: string;
public dedicatedFileTypeSummaries: string[] = [
...DEFAULT_SETTINGS.dedicatedFileTypeSummaries
];
public fileChangelogInterval: ChangelogInterval =
DEFAULT_SETTINGS.fileChangelogInterval;
public fileExplorerInterval: string = DEFAULT_SETTINGS.fileExplorerInterval;
public fileExplorerStats: FileExplorerStats =
DEFAULT_SETTINGS.fileExplorerStats;
public filesChangesWarningThreshold: string =
DEFAULT_SETTINGS.filesChangesWarningThreshold;
public fileSummariesDisplayMode: FilesSummariesDisplayMode =
DEFAULT_SETTINGS.fileSummariesDisplayMode;
public locale: string = DEFAULT_SETTINGS.locale;
public notifyOnContentDeletionsAndMovesThresholdReached: boolean =
DEFAULT_SETTINGS.notifyOnContentDeletionsAndMovesThresholdReached;
public notifyOnFilesChangesThresholdReached: boolean =
DEFAULT_SETTINGS.notifyOnFilesChangesThresholdReached;
public statusBarInterval: string = DEFAULT_SETTINGS.statusBarInterval;
public statusBarStats: boolean = DEFAULT_SETTINGS.statusBarStats;
// Specific Changelog Generation Settings
public vaultChangelogInterval: ChangelogInterval =
DEFAULT_SETTINGS.vaultChangelogInterval;
public constructor(data: unknown) {
super();
// Object.assign(this, DEFAULT_SETTINGS);
this.init(data);
this._shouldSaveAfterLoad = true;
}
}
export const DEFAULT_CHANGELOG_GENERATION_SETTINGS: ChangelogGenerationSettings =
{
dayStartTime: 0,
detectMovedContent: true,
diffAlgorithm: DiffAlgorithm.Inherit,
gitDiffIgnore: '',
measurementUnit: DiffMeasurementUnit.Words,
renameDetectionSensitivity: 50,
renameLimit: '1000',
timezone: AUTO_DETECT_TIMEZONE_PLACEHOLDER
} as const;
export const DEFAULT_SETTINGS: IGitChangelogSettings = {
autoCommitDisabledWarningDismissed: false,
changelogGenerationSettings: DEFAULT_CHANGELOG_GENERATION_SETTINGS,
contentDeletionsAndMovesWarningThreshold: '2000',
dedicatedFileTypeSummaries: [] as const,
fileChangelogInterval: ChangelogInterval.Daily,
fileExplorerInterval: '4320', // In mins
fileExplorerStats: FileExplorerStats.Disabled,
filesChangesWarningThreshold: '50',
fileSummariesDisplayMode: FilesSummariesDisplayMode.Total,
locale: '',
notifyOnContentDeletionsAndMovesThresholdReached: true,
notifyOnFilesChangesThresholdReached: false,
statusBarInterval: '30', // In mins
statusBarStats: false,
vaultChangelogInterval: ChangelogInterval.Daily
} as const;

View file

@ -0,0 +1,91 @@
import type { GitChangelogPlugin } from 'GitChangelogPlugin.svelte.ts';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsTabBase';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
import { MiscellaneousButtons } from 'settings/ui/MiscellaneousButtons.ts';
import { AutoCommitDisabledWarning } from './ui/AutoCommitDisabledWarning.ts';
import { CustomTimeZone } from './ui/CustomTimeZone.ts';
import { DayStartTime } from './ui/DayStartTime.ts';
import { DiffAlgorithmOptions } from './ui/DiffAlgorithmOptions.ts';
import { GitDiffIgnore } from './ui/GitDiffIgnore.ts';
import { GitPluginWarning } from './ui/GitPluginWarning.ts';
import { RenameDetectionFileLimit } from './ui/RenameDetectionFileLimit.ts';
import { RenameDetectionSensitivitySlider } from './ui/RenameDetectionSensitivitySlider.ts';
import { StatusBarInterval } from './ui/StatusBarInterval.ts';
import { StatusBarStatsToggle } from './ui/StatusBarStatsToggle.ts';
// Commented-out settings are for features that will be implemented later
export class GitChangelogSettingsTab extends PluginSettingsTabBase<GitChangelogPlugin> {
public override display(): void {
const { containerEl, plugin } = this;
containerEl.empty();
// Const notifyOnLargeChanges =
// Plugin.settings.notifyIfContentDeletionsAndMovesThresholdReached ??
// DEFAULT_SETTINGS.notifyIfContentDeletionsAndMovesThresholdReached;
new GitPluginWarning({ containerEl, plugin }).display();
new AutoCommitDisabledWarning({
containerEl,
plugin
}).display();
new DayStartTime({ containerEl, plugin }).display();
new CustomTimeZone({ containerEl, plugin }).display();
new DiffAlgorithmOptions({
containerEl,
plugin
}).display();
// New DeletionsNotificationThreshold(plugin, containerEl, false, this).display();
// New configureDeletionsMovesAlert(
// Plugin,
// ContainerEl,
// !notifyOnLargeChanges
// ).display();
// New FileChangesNotificationThreshold(
// Plugin,
// ContainerEl,
// NotifyOnLargeChanges
// ).display();
new StatusBarStatsToggle({
containerEl,
plugin,
settingTab: this
}).display();
new StatusBarInterval({
containerEl,
disabled: !(
plugin.settings.statusBarStats ?? DEFAULT_SETTINGS.statusBarStats
),
plugin
}).display();
// New ChangelogStatsInFileExplorerOptions(plugin, containerEl, false, this).display();
// New FileExplorerStatsInterval(
// Plugin,
// ContainerEl,
// (plugin.settings?.fileExplorerChangelogStats ??
// DEFAULT_SETTINGS.fileExplorerChangelogStats) ===
// FileExplorerChangelogStats.Disabled
// ).display();
new GitDiffIgnore({ containerEl, plugin }).display();
new RenameDetectionSensitivitySlider({
containerEl,
plugin
}).display();
new RenameDetectionFileLimit({
containerEl,
plugin
}).display();
new MiscellaneousButtons({
containerEl,
plugin
}).display();
}
// New DetectMovedContentToggle(plugin, containerEl).display();
// New ChangelogMeasurementUnit(plugin, containerEl).display();
}

View file

@ -0,0 +1,31 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
export class AutoCommitDisabledWarning extends GitChangelogSetting {
public display(): void {
try {
const gitPlugin = this.plugin.getGitPlugin();
if (
!this.plugin.settings.autoCommitDisabledWarningDismissed &&
gitPlugin.settings.autoSaveInterval <= 0
) {
const warningSetting = this.createSetting()
.setName('⚠️ Auto-commit setting not enabled')
.setDesc("It's recommended to enable this in Git plugin's settings.")
.setClass('git-changelog-warning')
.addButton((button) => {
button.setButtonText('Dismiss');
button.onClick(() => {
warningSetting.settingEl.remove();
const newSettings = this.plugin.settingsClone;
newSettings.autoCommitDisabledWarningDismissed = true;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
});
}
} catch {
/* Empty */
}
}
}

View file

@ -0,0 +1,51 @@
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
import { DiffMeasurementUnit } from 'types.ts';
export class ChangelogMeasurementUnit extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Changelog measurement unit')
.addDropdown((dropdown) => {
const options: Record<DiffMeasurementUnit, string> = {
Lines: 'Lines',
Words: 'Words'
};
dropdown.addOptions(options);
dropdown.setValue(
getMeasurementUnit(this.plugin.settings.changelogGenerationSettings)
);
dropdown.onChange((value: string) => {
const option =
DiffMeasurementUnit[value as keyof typeof DiffMeasurementUnit];
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.measurementUnit = option;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
});
}
}
export function getMeasurementUnit(
changelogGenerationSettings: ChangelogGenerationSettings
): DiffMeasurementUnit {
return DiffMeasurementUnit.Lines;
if (!validateMeasurementUnit(changelogGenerationSettings.measurementUnit)) {
return DEFAULT_SETTINGS.changelogGenerationSettings.measurementUnit;
}
return changelogGenerationSettings.measurementUnit;
}
export function validateMeasurementUnit(
measurementUnit: DiffMeasurementUnit
): boolean {
if (Object.values(DiffMeasurementUnit).includes(measurementUnit)) {
return true;
}
return false;
}

View file

@ -0,0 +1,33 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
import { FileExplorerStats } from 'types.ts';
export class ChangelogStatsInFileExplorerOptions extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Show changelog stats in File Explorer')
.addDropdown((dropdown) => {
const options: Record<FileExplorerStats, string> = {
Disabled: 'Disabled',
Folders: 'Folders',
FoldersAndNotes: 'Notes'
};
dropdown.addOptions(options);
dropdown.setValue(
this.plugin.settings.fileExplorerStats ??
DEFAULT_SETTINGS.fileExplorerStats
);
dropdown.onChange((value: string) => {
const option =
FileExplorerStats[value as keyof typeof FileExplorerStats];
this.refreshDisplaySmooth(0);
const newSettings = this.plugin.settingsClone;
newSettings.fileExplorerStats = option;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
});
}
}

View file

@ -0,0 +1,41 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
export class ContentDeletionsMovesThreshold extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Custom threshold for deletions/moves alert')
.setDesc(
"Acceptable amount of deletions and moves between commits. Represents either words or lines, depending on your setup. note that this doesn't mean between each interval but between the actual commits meaning if your auto-commit interval is five minutes this will trigger only if you manage to delete that many files inside those five minutes which usually signals corruption or data loss"
)
.addText((text) => {
text.setDisabled(this.disabled).onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.contentDeletionsAndMovesWarningThreshold =
validateContentDeletionsMovesThreshold(value)
? value
: DEFAULT_SETTINGS.contentDeletionsAndMovesWarningThreshold;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
// This.restrictToPositiveIntegerInput(text);
this.setValueIfNonDefaultSetting({
settingsProperty: 'contentDeletionsAndMovesWarningThreshold',
text
});
});
}
}
export function validateContentDeletionsMovesThreshold(
contentDeletionsMovesThreshold: string
): boolean {
if (
!Number.isInteger(Number(contentDeletionsMovesThreshold)) ||
Number(contentDeletionsMovesThreshold) < 1
) {
return false;
}
return true;
}

View file

@ -0,0 +1,77 @@
import type GitChangelogPlugin from 'main.ts';
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import { Notice } from 'obsidian';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { TimeZoneSuggest } from 'settings/components/suggest.ts';
import { DEFAULT_SETTINGS, TIME_ZONES_LIST } from 'settings/settings.ts';
export class CustomTimeZone extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Timezone')
.addText((text) => {
text
.setPlaceholder(DEFAULT_SETTINGS.changelogGenerationSettings.timezone)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.timezone = value;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
this.setValueIfNonDefaultSetting({
diffSettingsProperty: 'timezone',
settingsProperty: 'changelogGenerationSettings',
text
});
new TimeZoneSuggest(this.plugin.app, text.inputEl);
});
}
}
export function detectSystemTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase();
}
export function getSystemTimeZone(plugin: GitChangelogPlugin): string {
if (plugin.detectedTimeZone !== undefined) {
return plugin.detectedTimeZone;
}
const systemTimeZone = detectSystemTimeZone();
if (validateCustomTimeZone(systemTimeZone)) {
plugin.detectedTimeZone = systemTimeZone;
} else {
new Notice(
"Couldn't detect a valid system time zone: Obsidian installer version might be too old.\nFallback to UTC."
);
plugin.detectedTimeZone = 'utc';
return 'utc';
}
return plugin.detectedTimeZone;
}
export function getTimeZone(
changelogGenerationSettings: ChangelogGenerationSettings,
plugin: GitChangelogPlugin
): string {
return validateCustomTimeZone(changelogGenerationSettings.timezone)
? changelogGenerationSettings.timezone
: getSystemTimeZone(plugin);
}
export function systemTimeZoneUnchanged(plugin: GitChangelogPlugin): boolean {
const systemTimeZone = detectSystemTimeZone();
const cachedSystemTimeZone = plugin.detectedTimeZone;
if (systemTimeZone !== cachedSystemTimeZone) {
plugin.detectedTimeZone = systemTimeZone;
return false;
}
return true;
}
export function validateCustomTimeZone(timezone: string): boolean {
return TIME_ZONES_LIST.has(timezone.toLowerCase());
}

View file

@ -0,0 +1,51 @@
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import { moment } from 'obsidian';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
export class DayStartTime extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Day start time')
.setDesc('Adjust the day based on your schedule.')
.addTime((text) => {
text.inputEl.addClass('git-changelog-time-component');
text
.setValue(
moment.duration({
minutes:
this.plugin.settings.changelogGenerationSettings.dayStartTime
})
)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.dayStartTime =
value.asMinutes();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
});
}
}
export function getDayStartTime(
changelogGenerationSettings: ChangelogGenerationSettings
): number {
return validateDayStartTime(changelogGenerationSettings.dayStartTime)
? changelogGenerationSettings.dayStartTime
: DEFAULT_SETTINGS.changelogGenerationSettings.dayStartTime;
}
export const ONE_DAY_IN_MINUTES = 1440;
export function validateDayStartTime(dayStartTime: number): boolean {
if (
!Number.isInteger(dayStartTime) ||
dayStartTime < 0 ||
dayStartTime >= ONE_DAY_IN_MINUTES
) {
return false;
}
return true;
}

View file

@ -0,0 +1,27 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
export class DeletionsNotificationThreshold extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Notify on large amount of changes.')
.setDesc(
'Notify if changes between neighboring commits exceed a threshold. Which can be a sign of data loss or corruption.'
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings
.notifyOnContentDeletionsAndMovesThresholdReached
)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.notifyOnContentDeletionsAndMovesThresholdReached =
value;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
this.refreshDisplaySmooth();
})
);
}
}

View file

@ -0,0 +1,23 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
export class DetectMovedContentToggle extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Detect moved lines/words')
.setDesc(
`If enabled, changelog will also track all moved words or lines between files or moved to another location in the same file. Adds significant computational overhead that increases with the number and size of changes`
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.changelogGenerationSettings.detectMovedContent
)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.detectMovedContent = value;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
})
);
}
}

View file

@ -0,0 +1,50 @@
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
import { DiffAlgorithm } from 'types.ts';
export class DiffAlgorithmOptions extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Difference detection algorithm')
.setDesc(
"It's recommended to set a custom diff algorithm in git config instead of here, for consistency with the Diff viewer and other plugins."
)
.addDropdown((dropdown) => {
const options: Record<DiffAlgorithm, string> = {
Default: 'Default (Faster)', // Myers
Inherit: 'Use git config',
Minimal: 'Minimal (More Precise)'
};
dropdown.addOptions(options);
dropdown.setValue(
getDiffAlgorithm(this.plugin.settings.changelogGenerationSettings)
);
dropdown.onChange((value: string) => {
const option = DiffAlgorithm[value as keyof typeof DiffAlgorithm];
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.diffAlgorithm = option;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
});
}
}
export function getDiffAlgorithm(
changelogGenerationSettings: ChangelogGenerationSettings
): DiffAlgorithm {
if (
!Object.values(DiffAlgorithm).includes(
changelogGenerationSettings.diffAlgorithm
)
) {
return DEFAULT_SETTINGS.changelogGenerationSettings.diffAlgorithm;
}
return changelogGenerationSettings.diffAlgorithm;
}

View file

@ -0,0 +1,39 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
export class FileChangesNotificationThreshold extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Custom threshold for file changes alert')
.addText((text) => {
text.setDisabled(this.disabled).onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.contentDeletionsAndMovesWarningThreshold =
validateFileChangesNotificationThreshold(value)
? value
: DEFAULT_SETTINGS.contentDeletionsAndMovesWarningThreshold;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
// This.restrictToPositiveIntegerInput(text);
this.setValueIfNonDefaultSetting({
settingsProperty: 'filesChangesWarningThreshold',
text
});
});
}
}
export function validateFileChangesNotificationThreshold(
fileChangesNotificationThreshold: string
): boolean {
if (
!Number.isInteger(Number(fileChangesNotificationThreshold)) ||
Number(fileChangesNotificationThreshold) < 1
) {
return false;
}
return true;
}

View file

@ -0,0 +1,44 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS, MAX_SUPPORTED_INTERVAL } from 'settings/settings.ts';
export class FileExplorerStatsInterval extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Interval for File explorer stats (minutes)')
.setDesc(
'If specified, generates stats instead of relying on stats from the Changelog view.'
)
.addText((text) => {
text.setDisabled(this.disabled).onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.fileExplorerInterval = validateFileExplorerStatsInterval(
value
)
? value
: DEFAULT_SETTINGS.fileExplorerInterval;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
// This.restrictToPositiveIntegerInput(text, 5);
this.setValueIfNonDefaultSetting({
settingsProperty: 'fileExplorerInterval',
text
});
});
}
}
export function validateFileExplorerStatsInterval(
fileExplorerStatsInterval: string
): boolean {
if (
!Number.isInteger(Number(fileExplorerStatsInterval)) ||
Number(fileExplorerStatsInterval) < 1 ||
Number(fileExplorerStatsInterval) > MAX_SUPPORTED_INTERVAL
) {
return false;
}
return true;
}

View file

@ -0,0 +1,194 @@
// Import { compile } from "@gerhobbelt/gitignore-parser";
import type { GitChangelogPlugin } from 'GitChangelogPlugin.svelte.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
export class GitDiffIgnore extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Exclude files and folders (experimental)')
.setDesc(
createFragment((fragment) => {
fragment.appendText(
"Items listed here will still be committed to your repository but won't be included in the vault changelog. Use the same "
);
fragment.createEl('a', {
href: 'https://gitcheatsheet.org/how-to/git-gitignore',
text: 'syntax'
});
fragment.appendText(
' as .gitignore, but note that negation patterns are not yet supported. The main .gitignore file still applies.'
);
})
)
.addTextArea((text) => {
text
.setValue(
this.plugin.settings.changelogGenerationSettings.gitDiffIgnore ??
DEFAULT_SETTINGS.changelogGenerationSettings.gitDiffIgnore
)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.gitDiffIgnore = value;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
text.inputEl.classList.add('git-changelog-text-area');
});
}
}
/**
* Doesn't work properly, using same gitignore for all submodules for now
*/
export function adjustGitignoreForSubmodule(
gitignoreContent: string,
submodulePath: string
): string {
// Ensure submodulePath ends with a slash for matching purposes.
if (!submodulePath.endsWith('/')) {
submodulePath += '/';
}
// Regex for detecting glob characters.
const globRegex = /[*?[\]]/;
const lines = gitignoreContent.split('\n');
const adjustedLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Keep blank lines and comments unchanged.
if (trimmed === '' || trimmed.startsWith('#')) {
adjustedLines.push(line);
continue;
}
let isNegation = false;
let rule = trimmed;
// Handle negation: remove the leading "!" for processing.
if (rule.startsWith('!')) {
isNegation = true;
rule = rule.slice(1).trim();
}
// If the rule explicitly starts with the submodule path,
// Remove that prefix so it is relative to the submodule.
if (rule.startsWith(submodulePath)) {
const adjustedRule = rule.slice(submodulePath.length);
adjustedLines.push(isNegation ? `!${adjustedRule}` : adjustedRule);
} else if (globRegex.test(rule)) {
// If the rule doesn't start with the submodule path...
// - If it is hardcoded (no glob characters), drop it.
// - If it contains globs, leave it intact.
adjustedLines.push(line); // Leave the original rule unchanged.
// Else: hardcoded rule outside the submodule is omitted.
}
}
// Remove any empty lines which might have resulted from omitting rules.
return adjustedLines.filter((l) => l.trim() !== '').join('\n');
}
export function convertGitIgnoreToPathspec(
plugin: GitChangelogPlugin
): string[] {
const gitDiffIgnore =
plugin.settings.changelogGenerationSettings.gitDiffIgnore;
try {
// Always exclude .git directory
const excludes: string[] = [':(exclude,glob)**/.git/**'];
const includes: string[] = [];
for (const line of gitDiffIgnore
.split('\n')
.map((diffLine) => diffLine.trim())) {
if (!(isValidGitIgnoreRule(line) && !line.startsWith('!'))) {
continue;
}
const formatted = formatGitIgnoreToPathspec(line);
// Handle patterns differently based on their format:
if (line.endsWith('/*')) {
// Single-level match
excludes.push(`:(exclude,glob)${formatted}`);
} else if (line.endsWith('/**') || line.endsWith('/')) {
// Recursive match
excludes.push(`:(exclude,glob)${formatted}`);
} else {
// All other patterns - match both item and contents
// Regardless of whether they contain special characters
excludes.push(
`:(exclude,glob)${formatted}`,
`:(exclude,glob)${formatted}/**`
);
}
}
return [...excludes, ...includes];
} catch (error) {
plugin.consoleDebug(`Error converting gitignore to pathspec: ${error}`);
return [];
}
}
export function isValidGitIgnoreRule(rule: string): boolean {
if (!rule || rule.trim().length === 0) {
return false;
}
if (rule.startsWith('#')) {
return false;
}
// Strip leading negation if present
const patternToCheck = rule.startsWith('!') ? rule.slice(1) : rule;
const parts = patternToCheck.split('\\');
for (let index = 0; index < parts.length - 1; index++) {
if (/\s/.test(parts[index])) {
return false;
}
}
if (patternToCheck.includes('//') || patternToCheck.includes('**/**')) {
return false;
}
// Quick check for square bracket balance
const openBrackets = (patternToCheck.match(/\[/g) ?? []).length;
const closeBrackets = (patternToCheck.match(/\]/g) ?? []).length;
if (openBrackets !== closeBrackets) {
return false;
}
return true;
}
function formatGitIgnoreToPathspec(pattern: string): string {
pattern = pattern.trim();
let rootRelative = false;
if (pattern.startsWith('/')) {
rootRelative = true;
pattern = pattern.slice(1);
}
// For non-root patterns, prefix with **/ for a recursive match
if (
!rootRelative &&
!pattern.startsWith('*/') &&
!pattern.startsWith('**/')
) {
pattern = `**/${pattern}`;
}
// If the pattern ends with a slash, add '**' for directory contents
if (pattern.endsWith('/') && !pattern.endsWith('*/')) {
pattern += '**';
}
return pattern;
}

View file

@ -0,0 +1,81 @@
import type { ObsidianGitPlugin } from 'gitPluginTypes.ts';
import type { Setting } from 'obsidian';
import { compareVersions } from 'compare-versions';
import {
MAX_TESTED_GIT_PLUGIN_VERSION,
MIN_COMPATIBLE_GIT_PLUGIN_VERSION,
PLUGIN_NAME
} from 'constants.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { GitPluginState } from 'types.ts';
export class GitPluginWarning extends GitChangelogSetting {
public display(): void {
let desc: DocumentFragment | string;
let setting: Setting;
const state = this.plugin.gitPluginState;
switch (state) {
case GitPluginState.Enabled: {
desc = '';
break;
}
case GitPluginState.IncompatibleVersion: {
desc = '⚠️ The installed Git plugin version is incompatible.';
break;
}
case GitPluginState.UntestedVersion: {
desc =
'Compatibility with the installed Git plugin version is not tested.';
// A button to continue using at your own risk
break;
}
default: {
desc = createFragment((fragment) => {
fragment.appendText(`⚠️ ${PLUGIN_NAME} requires the `);
fragment.createEl('a', {
href: 'https://github.com/Vinzent03/obsidian-git',
text: 'Obsidian Git'
});
fragment.appendText(' plugin to be installed and enabled.');
});
}
}
if (state !== GitPluginState.Enabled) {
setting = this.createSetting()
.setName(desc)
.setClass('git-changelog-warning');
if (state === GitPluginState.Uninitialized) {
setting.addButton((button) => {
button.setButtonText('Install');
button.onClick(() => {
window.open('obsidian://show-plugin?id=obsidian-git');
});
});
}
}
}
}
export function gitPluginCompatibleVersion(plugin: ObsidianGitPlugin): boolean {
if (
compareVersions(
plugin.manifest.version,
MIN_COMPATIBLE_GIT_PLUGIN_VERSION
) < 0
) {
return false;
}
return true;
}
export function gitPluginTestedVersion(plugin: ObsidianGitPlugin): boolean {
if (
compareVersions(plugin.manifest.version, MAX_TESTED_GIT_PLUGIN_VERSION) > 0
) {
return false;
}
return true;
}

View file

@ -0,0 +1,35 @@
import { Notice } from 'obsidian';
import { GitChangelogSetting } from 'settings/components/setting.ts';
export class MiscellaneousButtons extends GitChangelogSetting {
public display(): void {
const bugReportDiv = this.createSetting();
bugReportDiv.addButton((button) => {
button.setButtonText('Give feedback');
button.onClick(() => {
window.open('https://github.com/shumadrid/obsidian-git-changelog');
});
});
bugReportDiv.addButton((button) => {
button.setButtonText('Copy debug information');
button.onClick(async () => {
await globalThis.navigator.clipboard.writeText(
JSON.stringify(
{
gitPluginState: this.plugin.gitPluginState,
pluginVersion: this.plugin.manifest.version,
settings: this.plugin.settings
},
null,
// eslint-disable-next-line no-magic-numbers
4
)
);
new Notice(
'Debug information copied to clipboard. May contain sensitive information!'
);
});
});
}
}

View file

@ -0,0 +1,53 @@
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
// https://github.com/git/git/blob/58b5801aa94ad5031978f8e42c1be1230b3d352f/diff.c#L58 - 1000 default
export class RenameDetectionFileLimit extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Rename detection file limit')
.setDesc(
'If more than ~X files change, exhaustive rename detection wont run, though some renames may still be detected. Be aware of potential computation costs when setting higher limits. Set 0 for no limit.'
)
.addText((text) => {
text.inputEl.pattern = '[0-9]*';
// eslint-disable-next-line no-magic-numbers
text.inputEl.maxLength = 20;
text.inputEl.inputMode = 'numeric';
text
.setPlaceholder(
DEFAULT_SETTINGS.changelogGenerationSettings.renameLimit
)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.renameLimit = String(value);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
// This.restrictToPositiveIntegerInput(text);
this.setValueIfNonDefaultSetting({
diffSettingsProperty: 'renameLimit',
settingsProperty: 'changelogGenerationSettings',
text
});
});
}
}
export function getRenameLimit(
changelogGenerationSettings: ChangelogGenerationSettings
): number {
if (!validateRenameLimit(changelogGenerationSettings.renameLimit)) {
return Number(DEFAULT_SETTINGS.changelogGenerationSettings.renameLimit);
}
return Number(changelogGenerationSettings.renameLimit);
}
export function validateRenameLimit(renameLimit: string): boolean {
return Number(renameLimit) >= 0 && Number.isInteger(renameLimit);
}

View file

@ -0,0 +1,67 @@
import type { SliderComponent } from 'obsidian';
import type { ChangelogGenerationSettings } from 'settings/settings.ts';
import { ResetButton } from 'settings/components/resetButton.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
const MIN_RENAME_DETECTION_SENSITIVITY = 1;
const MAX_RENAME_DETECTION_SENSITIVITY = 100;
export class RenameDetectionSensitivitySlider extends GitChangelogSetting {
public display(): void {
let slider: SliderComponent;
const setting = this.createSetting()
.setName('File move/rename detection sensitivity')
.setDesc(
"Flag files as renamed if more than X% of the file hasn't changed. Adjust this if you notice the plugin missing valid moves/renames or showing false positives."
);
new ResetButton(setting.controlEl).onClick(() => {
slider.setValue(
DEFAULT_SETTINGS.changelogGenerationSettings.renameDetectionSensitivity
);
});
setting.addSlider((percent) => {
slider = percent;
percent
.setValue(
getRenameDetectionSensitivity(
this.plugin.settings.changelogGenerationSettings
)
)
.setLimits(
MIN_RENAME_DETECTION_SENSITIVITY,
MAX_RENAME_DETECTION_SENSITIVITY,
1
)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.changelogGenerationSettings.renameDetectionSensitivity =
value;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
})
.setDynamicTooltip();
});
}
}
export function getRenameDetectionSensitivity(
changelogGenerationSettings: ChangelogGenerationSettings
): number {
if (
!Number.isInteger(changelogGenerationSettings.renameDetectionSensitivity) ||
changelogGenerationSettings.renameDetectionSensitivity <
MIN_RENAME_DETECTION_SENSITIVITY ||
changelogGenerationSettings.renameDetectionSensitivity >
MAX_RENAME_DETECTION_SENSITIVITY
) {
return DEFAULT_SETTINGS.changelogGenerationSettings
.renameDetectionSensitivity;
}
return changelogGenerationSettings.renameDetectionSensitivity;
}

View file

@ -0,0 +1,59 @@
import type GitChangelogPlugin from 'main.ts';
import { GitChangelogSetting } from 'settings/components/setting.ts';
import { DEFAULT_SETTINGS, MAX_SUPPORTED_INTERVAL } from 'settings/settings.ts';
export class StatusBarInterval extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Interval for status bar stats (minutes)')
.setDesc(
'Works by comparing the live file version against the first commit after the interval.'
)
.addText((text) => {
text.inputEl.pattern = '[1-9][0-9]*';
text.inputEl.maxLength = MAX_SUPPORTED_INTERVAL.toString().length;
text.inputEl.inputMode = 'numeric';
text
.setDisabled(this.disabled)
.setPlaceholder(DEFAULT_SETTINGS.statusBarInterval)
.onChange((value) => {
const newSettings = this.plugin.settingsClone;
newSettings.statusBarInterval = validateStatusBarAlternateInterval(
String(value)
)
? String(value)
: DEFAULT_SETTINGS.statusBarInterval;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
});
this.setValueIfNonDefaultSetting({
settingsProperty: 'statusBarInterval',
text
});
});
}
}
export function getStatusBarAlternateInterval(
plugin: GitChangelogPlugin
): number {
if (!validateStatusBarAlternateInterval(plugin.settings.statusBarInterval)) {
return Number(DEFAULT_SETTINGS.statusBarInterval);
}
return Number(plugin.settings.statusBarInterval);
}
export function validateStatusBarAlternateInterval(
statusBarAlternateInterval: string
): boolean {
if (
!Number.isInteger(Number(statusBarAlternateInterval)) ||
Number(statusBarAlternateInterval) < 1 ||
Number(statusBarAlternateInterval) > MAX_SUPPORTED_INTERVAL
) {
return false;
}
return true;
}

View file

@ -0,0 +1,28 @@
import { GitChangelogSetting } from 'settings/components/setting.ts';
export class StatusBarStatsToggle extends GitChangelogSetting {
public display(): void {
this.createSetting()
.setName('Active note live status bar stats')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.statusBarStats)
.onChange(async (value) => {
this.refreshDisplaySmooth();
const newSettings = this.plugin.settingsClone;
newSettings.statusBarStats = value;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.plugin.saveSettings(newSettings);
if (value) {
this.plugin.assignStatusBar();
await this.plugin.statusBar?.updateStatusBarWithFileStats();
} else {
this.plugin.statusBar?.remove();
this.plugin.statusBar = undefined;
}
})
);
}
}

View file

@ -0,0 +1,70 @@
import type GitChangelogPlugin from 'main.ts';
import { DEFAULT_SETTINGS } from 'settings/settings.ts';
import { ChangelogInterval } from 'types.ts';
export function getChangelogInterval(
plugin: GitChangelogPlugin,
fileOrVault: 'file' | 'vault'
): ChangelogInterval {
const interval =
fileOrVault === 'file'
? plugin.settings.fileChangelogInterval
: plugin.settings.vaultChangelogInterval;
if (!validateChangelogInterval(interval)) {
return fileOrVault === 'file'
? DEFAULT_SETTINGS.fileChangelogInterval
: DEFAULT_SETTINGS.vaultChangelogInterval;
}
return interval;
}
export async function setNextChangelogInterval(
plugin: GitChangelogPlugin,
fileOrVault: 'file' | 'vault'
): Promise<void> {
let interval =
fileOrVault === 'file'
? plugin.settings.fileChangelogInterval
: plugin.settings.vaultChangelogInterval;
switch (interval) {
case ChangelogInterval.Daily: {
interval = ChangelogInterval.Weekly;
break;
}
case ChangelogInterval.Hourly: {
interval = ChangelogInterval.Daily;
break;
}
case ChangelogInterval.Monthly: {
interval = ChangelogInterval.Hourly;
break;
}
case ChangelogInterval.Weekly: {
interval = ChangelogInterval.Monthly;
break;
}
}
const newSettings = plugin.settingsClone;
// Update the actual setting
if (fileOrVault === 'file') {
newSettings.fileChangelogInterval = interval;
} else {
newSettings.vaultChangelogInterval = interval;
}
// "false" because this function is only called in the context of triggering a new changelog computation, so we don't want to trigger a check that usually runs for this function (trigger recompute if some changelog generation settings changed).
await plugin.saveSettings(newSettings, false);
}
export function validateChangelogInterval(
changelogInterval: ChangelogInterval
): boolean {
if (Object.values(ChangelogInterval).includes(changelogInterval)) {
return true;
}
return false;
}

View file

@ -0,0 +1,23 @@
import type GitChangelogPlugin from 'main.ts';
export function getUserLocale(plugin: GitChangelogPlugin): string {
const locale = plugin.settings.locale;
if (validateLocale(locale)) {
return locale;
}
if (locale) plugin.consoleDebug('Invalid locale:', locale);
// Decided against using the new Obsidian language API so that the plugin is compatible with older versions of Obsidian (for now).
return Intl.DateTimeFormat().resolvedOptions().locale;
}
export function validateLocale(locale?: string): boolean {
try {
if (!locale || typeof locale !== 'string') {
return false;
}
new Intl.Locale(locale);
return Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0;
} catch {
return false;
}
}

143
src/statusBar.ts Normal file
View file

@ -0,0 +1,143 @@
import { findFirstCommitBefore } from 'core/gitOperations/findFirstCommitBefore.ts';
import { runCheckIgnore } from 'core/gitOperations/runCheckIgnore.ts';
import { runWorkingDirFileDiff } from 'core/gitOperations/runWorkingDirFileDiff.ts';
import { MarkdownView } from 'obsidian';
import { getMeasurementUnit } from 'settings/ui/ChangelogMeasurementUnit.ts';
import { getStatusBarAlternateInterval } from 'settings/ui/StatusBarInterval.ts';
import { DiffMeasurementUnit } from 'types.ts';
import { getActiveGitFileFromView } from 'Views/helper.ts';
import type GitChangelogPlugin from './main.ts';
export class StatusBar {
public constructor(
private statusBarElement: HTMLElement,
private readonly plugin: GitChangelogPlugin
) {
// Initialize immediately
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateStatusBarWithFileStats();
this.plugin.registerEvent(
this.plugin.app.workspace.on(
'obsidian-git-changelog:generation-settings-changed',
() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateStatusBarWithFileStats();
}
)
);
this.plugin.registerEvent(
this.plugin.app.workspace.on('file-open', () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateStatusBarWithFileStats();
})
);
this.plugin.registerEvent(
this.plugin.app.workspace.on(
'obsidian-git-changelog:status-bar-settings-changed',
() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateStatusBarWithFileStats();
}
)
);
this.plugin.registerEvent(
this.plugin.app.workspace.on('editor-change', () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateStatusBarWithFileStats();
})
);
}
public remove(): void {
this.statusBarElement.remove();
}
public setStatusBar(text: string): void {
this.statusBarElement.setText(text);
}
public async updateStatusBarWithFileStats(): Promise<void> {
try {
if (this.plugin.settings.statusBarStats) {
this.plugin.statusBarCachedTimeframe = getStatusBarAlternateInterval(
this.plugin
);
const result = await this.getFileLatestDiffStats(
this.plugin.app.workspace.getActiveViewOfType(MarkdownView)
);
if (result) {
this.setStatusBar(result);
} else {
this.setStatusBar('');
}
}
} catch {
this.setStatusBar('');
}
}
private async getFileLatestDiffStats(
activeFileView: MarkdownView | null
): Promise<string | undefined> {
const activeGitFile = getActiveGitFileFromView(activeFileView, this.plugin);
if (!(activeGitFile && activeFileView)) {
return;
}
let additions = 0;
let deletions = 0;
const oldCommit = await findFirstCommitBefore({
filePath: activeGitFile,
minutes: getStatusBarAlternateInterval(this.plugin),
plugin: this.plugin
});
if (oldCommit) {
const baseStats = await runWorkingDirFileDiff({
oldCommit,
plugin: this.plugin
});
if (baseStats) {
additions = baseStats.additions;
deletions = baseStats.deletions;
} else {
// File is binary
return;
}
} else {
// For files with no commit found (either new or the interval is spanning entire history),
// We can use the current file's word/line count, but we need to handle
// Two cases differently:
// 1. New files not yet tracked by git -> show line/word count
// 2. Git ignored files -> show nothing to avoid misleading stats (don't show 0s)
const fileIsGitIgnored = await runCheckIgnore({
activeGitFile,
plugin: this.plugin
});
if (fileIsGitIgnored) {
return;
}
const measurementUnit = getMeasurementUnit(
this.plugin.settings.changelogGenerationSettings
);
if (measurementUnit === DiffMeasurementUnit.Lines) {
additions = activeFileView.editor.lineCount();
} else if (measurementUnit === DiffMeasurementUnit.Words) {
// Additions = activeFileView.editor.getValue().split(/\s+/).length;
// To be implemented
}
}
return `+${additions} -${deletions}`;
}
}

156
src/styles/main.scss Normal file
View file

@ -0,0 +1,156 @@
%flex-center {
display: inline-flex;
align-items: center;
justify-content: center;
}
.git-changelog-disabled {
opacity: 0.5;
}
.git-changelog-warning {
border-bottom: 1px solid var(--text-warning);
}
// Status colors
.git-changelog-stat-color {
opacity: var(--git-changelog-opacity);
&[data-type='A'],
&[data-type='Additions'] {
color: var(--color-green);
}
&[data-type='R'],
&[data-type='Internal Moves'],
&[data-type='Incoming Moves'],
&[data-type='Outgoing Moves'] {
color: var(--color-orange);
}
&[data-type='M'] {
color: var(--color-gray);
}
&[data-type='D'],
&[data-type='Deletions'] {
color: var(--color-red);
}
&[data-type='F'],
&[data-type='RF'] {
color: var(--color-cyan);
}
}
.git-changelog-time-component {
background-color: var(--color-green);
-webkit-app-region: no-drag;
background: var(--background-modifier-form-field);
border: var(--input-border-width) solid var(--background-modifier-border);
color: var(--text-normal);
font-family: inherit;
padding: var(--size-4-1) var(--size-4-2);
font-size: var(--font-ui-small);
border-radius: var(--input-radius);
outline: none;
}
// Stats styling
.git-changelog-stat {
@extend %flex-center;
gap: var(--size-4-2);
height: 100%;
margin-bottom: var(--size-4-1);
flex-wrap: nowrap;
min-width: 0;
}
.git-changelog-stat-item {
@extend %flex-center;
gap: 2px;
height: 100%;
font-size: var(--font-small);
flex-grow: 1;
.invisible {
visibility: hidden;
}
.icon {
@extend %flex-center;
height: var (--size-4-2);
width: var (--size-4-2);
}
.file-icon {
@extend %flex-center;
height: var(--size-4-3);
width: var(--size-4-3);
}
.number {
@extend %flex-center;
font-weight: var(--font-normal);
min-width: 1ch;
}
}
.git-changelog-stat-item-file {
@extend .git-changelog-stat-item;
gap: 1px;
}
// Layout components
.git-changelog-text-area {
width: 100%;
max-width: 500px;
min-width: 150px;
min-height: 5em;
}
.git-changelog-view {
--git-changelog-opacity: 0.84;
display: flex;
flex-direction: column;
position: relative;
height: 100%;
padding-bottom: calc(2 * var(--size-4-1));
}
.git-changelog-entry-title {
color: var(--text-accent);
display: flex;
gap: var(--size-4-3);
margin-bottom: var(--size-4-1);
}
.git-changelog-one-line {
flex: 1;
flex-wrap: nowrap;
text-overflow: clip;
overflow: hidden;
white-space: nowrap;
}
.git-changelog-changelog-date {
@extend .git-changelog-one-line;
flex: 0 1 auto;
min-width: min-content;
margin-right: var(--size-4-2);
}
.git-changelog-file-status-letter {
text-align: center;
align-items: center;
margin-right: 0;
width: 0.9em;
flex-shrink: 2;
min-width: 0;
overflow: hidden;
}
/* Sticky view header and padding fixes */
.workspace-leaf-content {
&[data-type='vault-changelog-view'] .view-content,
&[data-type='file-changelog-view'] .view-content {
padding: 0;
}
}

34
src/timeUtils.ts Normal file
View file

@ -0,0 +1,34 @@
/* eslint-disable unicorn/prevent-abbreviations */
import type { Spacetime } from 'spacetime';
import type { LogEntry } from 'types.ts';
/**
* This function produces "fullyAdjusted" dates, which are dates that have the "day start time" setting applied to a "timeAdjustedDate". timezoneAdjustedDate is a date adjusted with the timezone setting specified in the settings tab.
*/
export function applyDayStartTimeSetting({
dayStartTime,
timezoneAdjustedDate
}: {
dayStartTime: number;
timezoneAdjustedDate: Spacetime;
}): Spacetime {
return timezoneAdjustedDate.subtract(dayStartTime, 'minutes');
}
export function getDayStartTimeAdjustedLogs(
logEntries: LogEntry[],
dayStartTime: number
): LogEntry[] {
if (dayStartTime === 0) {
return logEntries;
}
return logEntries.map((entry) => {
return {
...entry,
fullyAdjustedDate: applyDayStartTimeSetting({
dayStartTime,
timezoneAdjustedDate: entry.timezoneAdjustedDate
})
};
});
}

166
src/types.ts Normal file
View file

@ -0,0 +1,166 @@
import type { EventRef } from 'obsidian';
import type { Spacetime } from 'spacetime';
export class GitPluginIncompatibleVersionError extends Error {
public constructor() {
super("Current Git plugin version isn't compatible with this plugin.");
this.name = 'GitPluginIncompatibleVersionError';
}
}
export class GitPluginMissingError extends Error {
public constructor() {
super("Git plugin isn't enabled.");
this.name = 'GitPluginMissingError';
}
}
export class GitRepoMissingError extends Error {
public constructor() {
super('Git repository is not initialized or is in an irregular state.');
this.name = 'GitRepoMissingError';
}
}
export class AbortError extends Error {
public constructor() {
super('Task aborted');
this.name = 'AbortError';
}
}
declare module 'obsidian' {
interface Workspace {
on(
name:
| 'obsidian-git-changelog:active-file-changed'
| 'obsidian-git-changelog:file-changelog-generation-settings-changed'
| 'obsidian-git-changelog:generation-settings-changed'
| 'obsidian-git-changelog:status-bar-settings-changed'
| 'obsidian-git-changelog:vault-changelog-generation-settings-changed',
callback: () => void,
context?: unknown
): EventRef;
// BUG:? Read directly from settings instead of passing as arguments?
trigger(
name:
| 'obsidian-git-changelog:active-file-changed'
| 'obsidian-git-changelog:file-changelog-generation-settings-changed'
| 'obsidian-git-changelog:generation-settings-changed'
| 'obsidian-git-changelog:status-bar-settings-changed'
| 'obsidian-git-changelog:vault-changelog-generation-settings-changed'
): void;
}
}
export enum ChangelogInterval {
Daily = 'day',
Hourly = 'hour',
Monthly = 'month',
Weekly = 'week'
}
export enum DiffAlgorithm {
Default = 'Default', // Myers
Inherit = 'Inherit',
Minimal = 'Minimal'
}
/**
* "T : file type changed" are treated as renames.
* No ETA for detecting "C : copied" yet.
*/
export enum DiffFileStatus {
Added = 'A',
Deleted = 'D',
Modified = 'M', // Only if not renamed/moved
// Custom statuses
Moved = 'F',
Renamed = 'R',
RenamedAndMoved = 'RF'
}
export enum DiffMeasurementUnit {
Lines = 'Lines',
Words = 'Words'
}
export enum FileExplorerStats {
Disabled = 'Disabled',
Folders = 'Folders',
FoldersAndNotes = 'FoldersAndNotes'
}
export enum FilesSummariesDisplayMode {
TextAndBinary = 'Text And Binary',
Total = 'Total'
// Binary = "Binary",
}
export enum GitPluginState {
Uninitialized,
IncompatibleVersion,
UntestedVersion,
Enabled
}
export enum OnFileClick {
OpenFile = 'Open',
ShowDiff = 'Diff'
}
export interface DiffFile {
fromPathGitRelative?: string; // Only for renamed files
pathGitRelative: string;
// BlobHash: string;
status: DiffFileStatus;
textDiffStats?: TextDiffStats; // Keep undefined if the file is binary
}
export interface FileLogEntry extends LogEntry {
filePath: NonNullable<LogEntry['filePath']>;
}
export interface FilesSummary {
addedFiles: number;
deletedFiles: number;
modifiedFiles: number;
renamedFiles: number;
}
export interface LogEntry {
// For file git logs only, to track file renames through history
filePath?: string;
hash: string;
timezoneAdjustedDate: Spacetime;
}
export interface StatEntry {
count: number;
icon: string;
type:
| 'Additions'
| 'Deletions'
| 'Incoming Moves'
| 'Internal Moves'
| 'Outgoing Moves';
}
export interface TextDiffBaseStats {
additions: number;
deletions: number;
}
export interface TextDiffFile extends DiffFile {
textDiffStats: NonNullable<DiffFile['textDiffStats']>;
}
export interface TextDiffMoveStats {
incomingMoves: number;
internalMoves: number;
outgoingMoves: number;
}
export interface TextDiffStats {
baseStats: TextDiffBaseStats;
moveStats?: TextDiffMoveStats;
}

127
src/utils.ts Normal file
View file

@ -0,0 +1,127 @@
/* eslint-disable unicorn/prevent-abbreviations */
import type { App, WorkspaceLeaf } from 'obsidian';
import type { DiffFile, TextDiffStats } from 'types.ts';
// Import * as cssColorConverter from "css-color-converter";
import { Keymap, Menu } from 'obsidian';
import { DiffFileStatus } from 'types.ts';
export function getFileNameFromPath({
normalizedFilePath
}: {
normalizedFilePath: string;
}): string {
return normalizedFilePath.split('/').pop() ?? '';
}
export function getNewLeaf(
app: App,
event?: MouseEvent
): undefined | WorkspaceLeaf {
let leaf: undefined | WorkspaceLeaf;
if (event) {
if (event.button === 0 || event.button === 1) {
const type = Keymap.isModEvent(event);
leaf = app.workspace.getLeaf(type);
}
} else {
leaf = app.workspace.getLeaf(false);
}
return leaf;
}
export function insertSorted<T>(
array: T[],
value: T,
compareFunction: (a: T, b: T) => number
): void {
let left = 0;
let right = array.length;
while (left < right) {
// eslint-disable-next-line no-magic-numbers
const mid = Math.floor((left + right) / 2);
if (compareFunction(array[mid], value) < 0) {
left = mid + 1;
} else {
right = mid;
}
}
array.splice(left, 0, value); // Insert at the correct position
}
export function isMoved(file: DiffFile): boolean {
return (
file.status === DiffFileStatus.Moved ||
file.status === DiffFileStatus.RenamedAndMoved
);
}
export function isRenamed(file: DiffFile): boolean {
return (
file.status === DiffFileStatus.Renamed ||
file.status === DiffFileStatus.RenamedAndMoved
);
}
export function mayTriggerFileMenu({
app,
event,
filePath,
source,
view
}: {
app: App;
event: MouseEvent;
filePath: string;
source: string;
view: WorkspaceLeaf;
}): void {
// eslint-disable-next-line eqeqeq, no-magic-numbers
if (event.button == 2) {
const file = app.vault.getAbstractFileByPath(filePath);
// eslint-disable-next-line eqeqeq
if (file == undefined) {
const fileMenu = new Menu();
app.workspace.trigger(
'obsidian-git:menu',
fileMenu,
filePath,
source,
view
);
fileMenu.showAtPosition({ x: event.pageX, y: event.pageY });
} else {
const fileMenu = new Menu();
app.workspace.trigger('file-menu', fileMenu, file, source, view);
fileMenu.showAtPosition({ x: event.pageX, y: event.pageY });
}
}
}
export function parseContentChange({
addedStr,
deletedStr
}: {
addedStr: string;
deletedStr: string;
}): TextDiffStats {
let added = Number.parseInt(addedStr, 10);
if (Number.isNaN(added)) {
added = 0;
}
let deleted = Number.parseInt(deletedStr, 10);
if (Number.isNaN(deleted)) {
// ConsoleLog(
// `Failed to parse deleted lines: ${deletedStr} from ${filePath}`
// );
deleted = 0;
}
const textDiffStats: TextDiffStats = {
baseStats: { additions: added, deletions: deleted }
};
return textDiffStats;
}

31
tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"allowArbitraryExtensions": true,
"allowImportingTsExtensions": true,
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
"inlineSourceMap": true,
"inlineSources": true,
"lib": ["DOM", "ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"target": "ES2024",
"types": ["node", "svelte", "obsidian-typings"],
"verbatimModuleSyntax": true,
"paths": {
"*": ["*"]
},
"baseUrl": "src",
"strictBindCallApply": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"noImplicitThis": true
},
"include": ["./src/**/*.svelte", "./src/**/*.ts", "./scripts/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

1
versions.json Normal file
View file

@ -0,0 +1 @@
{}