mirror of
https://github.com/shumadrid/obsidian-git-changelog.git
synced 2026-07-22 05:42:16 +00:00
Initial commit
This commit is contained in:
commit
0994229493
83 changed files with 19049 additions and 0 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal 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
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
||||
61
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
61
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
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
BIN
.github/vault-changelog-view.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
39
.prettierignore
Normal 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
7
.prettierrc.json
Normal 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
21
LICENSE
Normal 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
157
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
> [!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
102
cspell.json
Normal 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
88
eslint.config.mjs
Normal 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
10
manifest.json
Normal 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
12185
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
61
package.json
Normal file
61
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
112
src/ChangelogTaskManager.svelte.ts
Normal file
112
src/ChangelogTaskManager.svelte.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
291
src/GitChangelogPlugin.svelte.ts
Normal file
291
src/GitChangelogPlugin.svelte.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
372
src/Views/FileChangelog/FileChangelog.svelte
Normal file
372
src/Views/FileChangelog/FileChangelog.svelte
Normal 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>
|
||||
64
src/Views/FileChangelog/FileChangelog.ts
Normal file
64
src/Views/FileChangelog/FileChangelog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
290
src/Views/VaultChangelog/VaultChangelog.svelte
Normal file
290
src/Views/VaultChangelog/VaultChangelog.svelte
Normal 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>
|
||||
63
src/Views/VaultChangelog/VaultChangelog.ts
Normal file
63
src/Views/VaultChangelog/VaultChangelog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
172
src/Views/VaultChangelog/components/File.svelte
Normal file
172
src/Views/VaultChangelog/components/File.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
172
src/Views/VaultChangelog/components/Version.svelte
Normal file
172
src/Views/VaultChangelog/components/Version.svelte
Normal 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>
|
||||
76
src/Views/components/ChangeIntervalButton.svelte
Normal file
76
src/Views/components/ChangeIntervalButton.svelte
Normal 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 can’t 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>
|
||||
38
src/Views/components/DependenciesStatusCheck.svelte
Normal file
38
src/Views/components/DependenciesStatusCheck.svelte
Normal 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>
|
||||
97
src/Views/components/DiffStats.svelte
Normal file
97
src/Views/components/DiffStats.svelte
Normal 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>
|
||||
56
src/Views/components/Stat.svelte
Normal file
56
src/Views/components/Stat.svelte
Normal 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
209
src/Views/formatters.ts
Normal 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
177
src/Views/helper.ts
Normal 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
139
src/Views/types.svelte.ts
Normal 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
32
src/commands.ts
Normal 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
19
src/constants.ts
Normal 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';
|
||||
60
src/core/gitOperations/findFirstCommitBefore.ts
Normal file
60
src/core/gitOperations/findFirstCommitBefore.ts
Normal 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];
|
||||
}
|
||||
129
src/core/gitOperations/helper.ts
Normal file
129
src/core/gitOperations/helper.ts
Normal 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
|
||||
);
|
||||
}
|
||||
32
src/core/gitOperations/isAncestor.ts
Normal file
32
src/core/gitOperations/isAncestor.ts
Normal 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;
|
||||
}
|
||||
15
src/core/gitOperations/runCheckIgnore.ts
Normal file
15
src/core/gitOperations/runCheckIgnore.ts
Normal 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;
|
||||
}
|
||||
91
src/core/gitOperations/runFileDiff.ts
Normal file
91
src/core/gitOperations/runFileDiff.ts
Normal 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;
|
||||
}
|
||||
72
src/core/gitOperations/runLog.ts
Normal file
72
src/core/gitOperations/runLog.ts
Normal 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)
|
||||
}));
|
||||
}
|
||||
228
src/core/gitOperations/runRepoDiff.ts
Normal file
228
src/core/gitOperations/runRepoDiff.ts
Normal 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;
|
||||
}
|
||||
65
src/core/gitOperations/runRepoDiffStatus.ts
Normal file
65
src/core/gitOperations/runRepoDiffStatus.ts
Normal 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;
|
||||
}
|
||||
44
src/core/gitOperations/runWorkingDirFileDiff.ts
Normal file
44
src/core/gitOperations/runWorkingDirFileDiff.ts
Normal 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
153
src/core/helper.ts
Normal 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
306
src/core/loadingEntries.ts
Normal 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
283
src/core/updatingEntries.ts
Normal 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
86
src/gitPluginTypes.ts
Normal 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
6
src/main.ts
Normal 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;
|
||||
15
src/settings/components/resetButton.ts
Normal file
15
src/settings/components/resetButton.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
93
src/settings/components/setting.ts
Normal file
93
src/settings/components/setting.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/settings/components/suggest.ts
Normal file
36
src/settings/components/suggest.ts
Normal 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
57
src/settings/helper.ts
Normal 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
134
src/settings/settings.ts
Normal 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;
|
||||
91
src/settings/settingsTab.ts
Normal file
91
src/settings/settingsTab.ts
Normal 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();
|
||||
}
|
||||
31
src/settings/ui/AutoCommitDisabledWarning.ts
Normal file
31
src/settings/ui/AutoCommitDisabledWarning.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/settings/ui/ChangelogMeasurementUnit.ts
Normal file
51
src/settings/ui/ChangelogMeasurementUnit.ts
Normal 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;
|
||||
}
|
||||
33
src/settings/ui/ChangelogStatsInFileExplorerOptions.ts
Normal file
33
src/settings/ui/ChangelogStatsInFileExplorerOptions.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
41
src/settings/ui/ContentDeletionsMovesThreshold.ts
Normal file
41
src/settings/ui/ContentDeletionsMovesThreshold.ts
Normal 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;
|
||||
}
|
||||
77
src/settings/ui/CustomTimeZone.ts
Normal file
77
src/settings/ui/CustomTimeZone.ts
Normal 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());
|
||||
}
|
||||
51
src/settings/ui/DayStartTime.ts
Normal file
51
src/settings/ui/DayStartTime.ts
Normal 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;
|
||||
}
|
||||
27
src/settings/ui/DeletionsNotificationThreshold.ts
Normal file
27
src/settings/ui/DeletionsNotificationThreshold.ts
Normal 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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/settings/ui/DetectMovedContentToggle.ts
Normal file
23
src/settings/ui/DetectMovedContentToggle.ts
Normal 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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/settings/ui/DiffAlgorithmOptions.ts
Normal file
50
src/settings/ui/DiffAlgorithmOptions.ts
Normal 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;
|
||||
}
|
||||
39
src/settings/ui/FileChangesNotificationThreshold.ts
Normal file
39
src/settings/ui/FileChangesNotificationThreshold.ts
Normal 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;
|
||||
}
|
||||
44
src/settings/ui/FileExplorerStatsInterval.ts
Normal file
44
src/settings/ui/FileExplorerStatsInterval.ts
Normal 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;
|
||||
}
|
||||
194
src/settings/ui/GitDiffIgnore.ts
Normal file
194
src/settings/ui/GitDiffIgnore.ts
Normal 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;
|
||||
}
|
||||
81
src/settings/ui/GitPluginWarning.ts
Normal file
81
src/settings/ui/GitPluginWarning.ts
Normal 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;
|
||||
}
|
||||
35
src/settings/ui/MiscellaneousButtons.ts
Normal file
35
src/settings/ui/MiscellaneousButtons.ts
Normal 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!'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/settings/ui/RenameDetectionFileLimit.ts
Normal file
53
src/settings/ui/RenameDetectionFileLimit.ts
Normal 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 won’t 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);
|
||||
}
|
||||
67
src/settings/ui/RenameDetectionSensitivitySlider.ts
Normal file
67
src/settings/ui/RenameDetectionSensitivitySlider.ts
Normal 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;
|
||||
}
|
||||
59
src/settings/ui/StatusBarInterval.ts
Normal file
59
src/settings/ui/StatusBarInterval.ts
Normal 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;
|
||||
}
|
||||
28
src/settings/ui/StatusBarStatsToggle.ts
Normal file
28
src/settings/ui/StatusBarStatsToggle.ts
Normal 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;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/settings/validation/changelogInterval.ts
Normal file
70
src/settings/validation/changelogInterval.ts
Normal 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;
|
||||
}
|
||||
23
src/settings/validation/userLocale.ts
Normal file
23
src/settings/validation/userLocale.ts
Normal 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
143
src/statusBar.ts
Normal 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
156
src/styles/main.scss
Normal 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
34
src/timeUtils.ts
Normal 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
166
src/types.ts
Normal 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
127
src/utils.ts
Normal 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
31
tsconfig.json
Normal 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
1
versions.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
Loading…
Reference in a new issue