mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: improve dependency injection type safety and add AI exclusions setting
- Add generic types to all Resolve() calls for type safety - Introduce VaultService abstraction layer for file operations - Add AI File Exclusions setting with textarea and glob pattern support - Refactor FileSystemService to use VaultService instead of direct Vault access - Improve code organization with comments and renamed variables - Update styles for exclusions input field
This commit is contained in:
parent
ae5a836c21
commit
e543bf5351
15 changed files with 180 additions and 52 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { AIProvider } from "Enums/ApiProvider";
|
||||
import { Path } from "Enums/Path";
|
||||
import type AIAgentPlugin from "main";
|
||||
import { PluginSettingTab, Setting, App, setIcon, setTooltip } from "obsidian";
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ export class AIAgentSettingTab extends PluginSettingTab {
|
|||
|
||||
containerEl.empty();
|
||||
|
||||
/* API Provider Setting */
|
||||
new Setting(containerEl)
|
||||
.setName("API Provider")
|
||||
.setDesc("Select the API provider to use.")
|
||||
|
|
@ -28,9 +30,10 @@ export class AIAgentSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.apiProvider = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
);
|
||||
|
||||
let inputEl: HTMLInputElement;
|
||||
/* API Key Setting */
|
||||
let apiKeyInputEl: HTMLInputElement;
|
||||
this.apiKeySetting = new Setting(containerEl)
|
||||
.setName("API Key")
|
||||
.setDesc("Enter your API key here.")
|
||||
|
|
@ -43,29 +46,42 @@ export class AIAgentSettingTab extends PluginSettingTab {
|
|||
this.highlightApiKey();
|
||||
});
|
||||
text.inputEl.type = "password";
|
||||
inputEl = text.inputEl;
|
||||
apiKeyInputEl = text.inputEl;
|
||||
})
|
||||
.addExtraButton(button => {
|
||||
button
|
||||
.setTooltip("Show API Key")
|
||||
.onClick(() => {
|
||||
if (inputEl.type === "password") {
|
||||
inputEl.type = "text";
|
||||
if (apiKeyInputEl.type === "password") {
|
||||
apiKeyInputEl.type = "text";
|
||||
setIcon(button.extraSettingsEl, "eye-off");
|
||||
setTooltip(button.extraSettingsEl, "Hide API Key");
|
||||
} else {
|
||||
inputEl.type = "password";
|
||||
apiKeyInputEl.type = "password";
|
||||
setIcon(button.extraSettingsEl, "eye");
|
||||
setTooltip(button.extraSettingsEl, "Show API Key");
|
||||
}
|
||||
});
|
||||
setIcon(button.extraSettingsEl, "eye");
|
||||
});
|
||||
|
||||
this.highlightApiKey();
|
||||
|
||||
/* Exclusions Setting */
|
||||
new Setting(containerEl)
|
||||
.setName("AI File Exclusions")
|
||||
.setDesc("Set which directories and files the AI should ignore. Enter one path per line - supports glob patterns like folder/**, *.md")
|
||||
.addTextArea(text => {
|
||||
text.setPlaceholder(`Examples:\n\n${Path.UserInstruction}\n${Path.Conversations}/*.json\nPrivateNotes/**`)
|
||||
.setValue(this.plugin.settings.exclusions.join("\n"))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exclusions = value.split("\n").map(line => line.trim()).filter(line => line.length > 0);
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
text.inputEl.classList.add("ai-exclusions-input");
|
||||
});
|
||||
}
|
||||
|
||||
highlightApiKey(): void {
|
||||
private highlightApiKey(): void {
|
||||
if (this.apiKeySetting) {
|
||||
if (this.plugin.settings.apiKey.trim() === "") {
|
||||
this.apiKeySetting.settingEl.removeClass("api-key-setting-ok");
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ export class Gemini implements IAIClass {
|
|||
private readonly STOP_REASON_STOP: string = "STOP";
|
||||
|
||||
private readonly apiKey: string;
|
||||
private readonly aiPrompt: IPrompt = Resolve(Services.IPrompt);
|
||||
private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin);
|
||||
private readonly streamingService: StreamingService = Resolve(Services.StreamingService);
|
||||
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve(Services.AIFunctionDefinitions);
|
||||
private readonly aiPrompt: IPrompt = Resolve<IPrompt>(Services.IPrompt);
|
||||
private readonly plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
|
||||
private readonly streamingService: StreamingService = Resolve<StreamingService>(Services.StreamingService);
|
||||
private readonly aiFunctionDefinitions: AIFunctionDefinitions = Resolve<AIFunctionDefinitions>(Services.AIFunctionDefinitions);
|
||||
private accumulatedFunctionName: string | null = null;
|
||||
private accumulatedFunctionArgs: Record<string, any> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
let thoughtElement: HTMLElement | undefined;
|
||||
let streamingElement: HTMLElement | undefined;
|
||||
|
||||
let streamingMarkdownService: StreamingMarkdownService = Resolve(Services.StreamingMarkdownService);
|
||||
let streamingMarkdownService: StreamingMarkdownService = Resolve<StreamingMarkdownService>(Services.StreamingMarkdownService);
|
||||
|
||||
let messageElements: Map<string, HTMLElement> = new Map<string, HTMLElement>();
|
||||
let lastProcessedContent: Map<string, string> = new Map<string, string>();
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
import type { ChatService } from "Services/ChatService";
|
||||
import type { ConversationFileSystemService } from "Services/ConversationFileSystemService";
|
||||
|
||||
let plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin);
|
||||
let chatService: ChatService = Resolve(Services.ChatService);
|
||||
let workSpaceService: WorkSpaceService = Resolve(Services.WorkSpaceService);
|
||||
let conversationService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService);
|
||||
let plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
|
||||
let chatService: ChatService = Resolve<ChatService>(Services.ChatService);
|
||||
let workSpaceService: WorkSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
|
||||
let conversationService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||
|
||||
let textareaElement: HTMLTextAreaElement;
|
||||
let chatContainer: HTMLDivElement;
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ interface ListItem {
|
|||
|
||||
export class ConversationHistoryModal extends Modal {
|
||||
|
||||
private readonly conversationFileSystemService: ConversationFileSystemService = Resolve(Services.ConversationFileSystemService);
|
||||
private readonly fileSystemService: FileSystemService = Resolve(Services.FileSystemService);
|
||||
private readonly conversationFileSystemService: ConversationFileSystemService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||
private readonly fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
|
||||
private component: Record<string, any> | null = null;
|
||||
private items: ListItem[];
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
|
|||
|
||||
export class AIFunctionService {
|
||||
|
||||
private fileSystemService: FileSystemService = Resolve(Services.FileSystemService);
|
||||
private fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
|
||||
public async performAIFunction(functionCall: AIFunctionCall): Promise<AIFunctionResponse> {
|
||||
switch (functionCall.name) {
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ export class ChatService {
|
|||
private semaphore: Semaphore;
|
||||
|
||||
constructor() {
|
||||
this.conversationService = Resolve(Services.ConversationFileSystemService);
|
||||
this.aiFunctionService = Resolve(Services.AIFunctionService);
|
||||
this.conversationService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
|
||||
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
|
||||
this.semaphore = new Semaphore(1, false);
|
||||
}
|
||||
|
||||
resolveAIProvider() {
|
||||
this.ai = Resolve(Services.IAIClass);
|
||||
this.ai = Resolve<IAIClass>(Services.IAIClass);
|
||||
}
|
||||
|
||||
async submit(conversation: Conversation, userRequest: string, callbacks: ChatServiceCallbacks): Promise<Conversation> {
|
||||
|
|
|
|||
|
|
@ -3,39 +3,41 @@ import { TAbstractFile, TFile, TFolder, type Vault } from "obsidian";
|
|||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import { isValidJson } from "Helpers/Helpers";
|
||||
import { Path } from "Enums/Path";
|
||||
import type { VaultService } from "./VaultService";
|
||||
|
||||
export class FileSystemService {
|
||||
|
||||
private vault: Vault;
|
||||
|
||||
private readonly vaultService: VaultService;
|
||||
|
||||
public constructor() {
|
||||
this.vault = Resolve<AIAgentPlugin>(Services.AIAgentPlugin).app.vault;
|
||||
this.vaultService = Resolve<VaultService>(Services.VaultService);
|
||||
}
|
||||
|
||||
public getVaultFileListForMarkDown() {
|
||||
const files: TFile[] = this.vault.getMarkdownFiles();
|
||||
const files: TFile[] = this.vaultService.getMarkdownFiles();
|
||||
return files.map(file => {
|
||||
return file.path.replace(/\.md$/, "");
|
||||
});
|
||||
}
|
||||
|
||||
public async readFile(filePath: string): Promise<string | null> {
|
||||
const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
|
||||
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
|
||||
if (file && file instanceof TFile) {
|
||||
return await this.vault.read(file);
|
||||
return await this.vaultService.read(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async writeFile(filePath: string, content: string): Promise<boolean> {
|
||||
try {
|
||||
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
|
||||
let file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
|
||||
if (file == null || !(file instanceof TFile)) {
|
||||
await this.createDirectories(this.vault, filePath);
|
||||
await this.vault.create(filePath, content);
|
||||
await this.createDirectories(this.vaultService, filePath);
|
||||
await this.vaultService.create(filePath, content);
|
||||
return true;
|
||||
}
|
||||
this.vault.modify(file as TFile, content);
|
||||
await this.vaultService.modify(file as TFile, content);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
|
|
@ -46,10 +48,10 @@ export class FileSystemService {
|
|||
|
||||
public async deleteFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
|
||||
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
|
||||
|
||||
if (file && file instanceof TFile) {
|
||||
await this.vault.delete(file);
|
||||
await this.vaultService.delete(file);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +63,7 @@ export class FileSystemService {
|
|||
}
|
||||
|
||||
public async listFilesInDirectory(dirPath: string, recursive: boolean = true): Promise<TFile[]> {
|
||||
const dir: TAbstractFile | null = this.vault.getAbstractFileByPath(dirPath);
|
||||
const dir: TAbstractFile | null = this.vaultService.getAbstractFileByPath(dirPath);
|
||||
|
||||
if (dir == null || !(dir instanceof TFolder)) {
|
||||
return [];
|
||||
|
|
@ -81,9 +83,9 @@ export class FileSystemService {
|
|||
}
|
||||
|
||||
public async readObjectFromFile(filePath: string): Promise<object | null> {
|
||||
const file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
|
||||
const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
|
||||
if (file && file instanceof TFile) {
|
||||
const content = await this.vault.read(file);
|
||||
const content = await this.vaultService.read(file);
|
||||
if (isValidJson(content) === true) {
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
|
@ -93,14 +95,14 @@ export class FileSystemService {
|
|||
|
||||
public async writeObjectToFile(filePath: string, data: object): Promise<boolean> {
|
||||
try {
|
||||
let file: TAbstractFile | null = this.vault.getAbstractFileByPath(filePath);
|
||||
let file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath);
|
||||
|
||||
if (file && file instanceof TFile) {
|
||||
await this.vault.modify(file, JSON.stringify(data, null, 4));
|
||||
await this.vaultService.modify(file, JSON.stringify(data, null, 4));
|
||||
}
|
||||
else {
|
||||
await this.createDirectories(this.vault, filePath);
|
||||
await this.vault.create(filePath, JSON.stringify(data, null, 4));
|
||||
await this.createDirectories(this.vaultService, filePath);
|
||||
await this.vaultService.create(filePath, JSON.stringify(data, null, 4));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -110,7 +112,7 @@ export class FileSystemService {
|
|||
}
|
||||
}
|
||||
|
||||
private async createDirectories(vault: Vault, filePath: string) {
|
||||
private async createDirectories(vaultService: VaultService, filePath: string) {
|
||||
const dirPath: string = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
|
||||
const dirs: string[] = dirPath.split('/');
|
||||
|
|
@ -119,8 +121,8 @@ export class FileSystemService {
|
|||
for (const dir of dirs) {
|
||||
if (dir) {
|
||||
currentPath = currentPath ? `${currentPath}/${dir}` : dir;
|
||||
if (vault.getAbstractFileByPath(currentPath) == null) {
|
||||
await vault.createFolder(currentPath);
|
||||
if (vaultService.getAbstractFileByPath(currentPath) == null) {
|
||||
await vaultService.createFolder(currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ import { StreamingService } from "./StreamingService";
|
|||
import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions";
|
||||
import { WorkSpaceService } from "./WorkSpaceService";
|
||||
import { ChatService } from "./ChatService";
|
||||
import { VaultService } from "./VaultService";
|
||||
|
||||
export function RegisterDependencies(plugin: AIAgentPlugin) {
|
||||
RegisterSingleton(Services.AIAgentPlugin, plugin);
|
||||
RegisterSingleton(Services.VaultService, new VaultService());
|
||||
RegisterSingleton(Services.MessageService, new MessageService());
|
||||
RegisterSingleton(Services.WorkSpaceService, new WorkSpaceService());
|
||||
RegisterSingleton(Services.FileSystemService, new FileSystemService());
|
||||
|
|
@ -40,8 +42,7 @@ export function RegisterAiProvider(plugin: AIAgentPlugin) {
|
|||
if (plugin.settings.apiProvider == AIProvider.Gemini) {
|
||||
RegisterSingleton<IAIClass>(Services.IAIClass, new Gemini());
|
||||
}
|
||||
const chatService: ChatService = Resolve(Services.ChatService);
|
||||
chatService.resolveAIProvider();
|
||||
Resolve<ChatService>(Services.ChatService).resolveAIProvider();
|
||||
}
|
||||
|
||||
function RegisterModals(app: App) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export class Services {
|
||||
static MessageService = Symbol("MessageService");
|
||||
static AIAgentPlugin = Symbol("AIAgentPlugin");
|
||||
static VaultService = Symbol("VaultService");
|
||||
static MessageService = Symbol("MessageService");
|
||||
static WorkSpaceService = Symbol("WorkSpaceService");
|
||||
static FileSystemService = Symbol("FileSystemService");
|
||||
static ConversationFileSystemService = Symbol("ConversationFileSystemService");
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ interface StreamingState {
|
|||
}
|
||||
|
||||
export class StreamingMarkdownService {
|
||||
private readonly fileSystemService: FileSystemService = Resolve(Services.FileSystemService);
|
||||
private readonly fileSystemService: FileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
||||
|
||||
private readonly processor: Processor<any, any, any, any, any> | null = null;
|
||||
private streamingStates = new Map<string, StreamingState>();
|
||||
|
|
|
|||
99
Services/VaultService.ts
Normal file
99
Services/VaultService.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { TAbstractFile, TFile, TFolder, Vault } from "obsidian";
|
||||
import { Resolve } from "./DependencyService";
|
||||
import { Services } from "./Services";
|
||||
import type AIAgentPlugin from "main";
|
||||
import { Path } from "Enums/Path";
|
||||
|
||||
/* This service protects the users vault through their exclusions. The plugin root is excluded by default */
|
||||
export class VaultService {
|
||||
|
||||
private readonly AGENT_ROOT = `${Path.Root}/**`;
|
||||
|
||||
private readonly plugin: AIAgentPlugin;
|
||||
private readonly vault: Vault;
|
||||
|
||||
public constructor() {
|
||||
this.plugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
|
||||
this.vault = this.plugin.app.vault;
|
||||
}
|
||||
|
||||
public getMarkdownFiles(): TFile[] {
|
||||
return this.vault.getMarkdownFiles().filter(file => !this.isExclusion(file.path));
|
||||
}
|
||||
|
||||
public getAbstractFileByPath(filePath: string): TAbstractFile | null {
|
||||
if (this.isExclusion(filePath)) {
|
||||
console.log(`Plugin attempted to retrieve a file that is in the exclusions list: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
return this.vault.getAbstractFileByPath(filePath);
|
||||
}
|
||||
|
||||
public async read(file: TFile): Promise<string> {
|
||||
if (this.isExclusion(file.path)) {
|
||||
console.log(`Plugin attempted to read a file that is in the exclusions list: ${file.path}`);
|
||||
return "";
|
||||
}
|
||||
return await this.vault.read(file);
|
||||
}
|
||||
|
||||
public async create(filePath: string, content: string): Promise<TFile> {
|
||||
if (this.isExclusion(filePath)) {
|
||||
throw new Error(`Plugin attempted to create a file that is in the exclusion list: ${filePath}`);
|
||||
}
|
||||
return await this.vault.create(filePath, content);
|
||||
}
|
||||
|
||||
public async modify(file: TFile, content: string): Promise<void> {
|
||||
if (this.isExclusion(file.path)) {
|
||||
console.log(`Plugin attempted to modify a file that is in the exclusions list: ${file.path}`)
|
||||
return;
|
||||
}
|
||||
await this.vault.modify(file, content);
|
||||
}
|
||||
|
||||
public async delete(file: TAbstractFile, force?: boolean): Promise<void> {
|
||||
if (this.isExclusion(file.path)) {
|
||||
console.log(`Plugin attempted to delete a file that is in the exclusions list: ${file.path}`)
|
||||
return;
|
||||
}
|
||||
await this.vault.delete(file, force);
|
||||
}
|
||||
|
||||
public async createFolder(path: string): Promise<TFolder> {
|
||||
if (this.isExclusion(path)) {
|
||||
throw new Error(`Plugin attempted to create a folder that is in the exclusion list: ${path}`);
|
||||
}
|
||||
return await this.vault.createFolder(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file should be excluded based on configured exclusion patterns.
|
||||
* Supports exact matches and glob patterns (**, *).
|
||||
*/
|
||||
private isExclusion(filePath: string): boolean {
|
||||
const exclusions = [this.AGENT_ROOT, ...this.plugin.settings.exclusions];
|
||||
|
||||
return exclusions.some(pattern => {
|
||||
if (filePath === pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let regexPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
|
||||
.replace(/\*\*/g, '::DOUBLESTAR::') // Temporarily replace **
|
||||
.replace(/\*/g, '[^/]*') // * matches anything except /
|
||||
.replace(/::DOUBLESTAR::/g, '.*'); // ** matches anything including /
|
||||
|
||||
// If pattern ends with /, match the directory and all its contents
|
||||
if (pattern.endsWith('/')) {
|
||||
regexPattern = regexPattern + '.*';
|
||||
}
|
||||
|
||||
// Add anchors for full path matching
|
||||
const regex = new RegExp('^' + regexPattern + '$');
|
||||
|
||||
return regex.test(filePath);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { Services } from "./Services";
|
|||
import type { TFile, WorkspaceLeaf } from "obsidian";
|
||||
|
||||
export class WorkSpaceService {
|
||||
private readonly plugin: AIAgentPlugin = Resolve(Services.AIAgentPlugin);
|
||||
private readonly plugin: AIAgentPlugin = Resolve<AIAgentPlugin>(Services.AIAgentPlugin);
|
||||
|
||||
public async openNote(noteName: string) {
|
||||
const file: TFile | null = this.plugin.app.metadataCache.getFirstLinkpathDest(noteName, "");
|
||||
|
|
|
|||
4
main.ts
4
main.ts
|
|
@ -8,11 +8,13 @@ import { AIAgentSettingTab } from 'AIAgentSettingTab';
|
|||
interface AIAgentSettings {
|
||||
apiProvider: string;
|
||||
apiKey: string;
|
||||
exclusions: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AIAgentSettings = {
|
||||
apiProvider: AIProvider.Gemini,
|
||||
apiKey: ""
|
||||
apiKey: "",
|
||||
exclusions: []
|
||||
}
|
||||
|
||||
export default class AIAgentPlugin extends Plugin {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Settings API Key Highlighting */
|
||||
/* Settings Styles */
|
||||
/* ============================== */
|
||||
|
||||
.api-key-setting-ok input[type="text"],
|
||||
|
|
@ -55,6 +55,13 @@
|
|||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.ai-exclusions-input {
|
||||
width: 350px;
|
||||
height: 110px;
|
||||
font: var(--font-monospace);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* CSS Variables for Common Components */
|
||||
/* ============================== */
|
||||
|
|
|
|||
Loading…
Reference in a new issue