ContextNavigationService.java
package com.taxonomy.versioning.service;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dto.ContextHistoryEntry;
import com.taxonomy.dto.ContextMode;
import com.taxonomy.dto.ContextRef;
import com.taxonomy.dto.NavigationReason;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import com.taxonomy.workspace.service.UserWorkspaceState;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceManager;
/**
* Manages per-user architecture context and navigation history.
*
* <p>Provides browser-like navigation: the user can open read-only snapshots,
* switch branches, return to origin, and navigate back through history.
*
* <p>All state is isolated per user via {@link WorkspaceManager}. Each user
* has their own current context, navigation history, and read-only state.
* Overloaded methods without a {@code username} parameter use
* {@link WorkspaceManager#DEFAULT_USER} for backward compatibility with
* tests and unauthenticated callers.
*/
@Service
public class ContextNavigationService {
private static final Logger log = LoggerFactory.getLogger(ContextNavigationService.class);
private final DslGitRepositoryFactory repositoryFactory;
private final RepositoryStateService stateService;
private final WorkspaceManager workspaceManager;
private final int maxHistory;
public ContextNavigationService(DslGitRepositoryFactory repositoryFactory,
RepositoryStateService stateService,
WorkspaceManager workspaceManager,
@Value("${taxonomy.context.max-history:50}") int maxHistory) {
this.repositoryFactory = repositoryFactory;
this.stateService = stateService;
this.workspaceManager = workspaceManager;
this.maxHistory = maxHistory;
}
/**
* Resolve the Git repository for the given workspace context.
*
* @param ctx the workspace context (use {@link WorkspaceContext#SHARED}
* for the system repository)
* @return the resolved DslGitRepository
*/
private DslGitRepository resolveRepository(WorkspaceContext ctx) {
return repositoryFactory.resolveRepository(ctx);
}
// ── Workspace-aware methods ─────────────────────────────────────
/**
* Get the current context reference for a user.
*
* @param username the user whose context to retrieve
* @return the current context, never null
*/
public ContextRef getCurrentContext(String username) {
return resolveState(username).getCurrentContext();
}
/**
* Open a read-only context for a specific branch and commit.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #openReadOnly(String, String, String, WorkspaceContext, String, String)}
* for workspace-aware resolution.
*
* @param username the user performing the navigation
* @param branch the branch to view
* @param commitId the commit SHA (null for HEAD)
* @param searchQuery the search query that led here (may be null)
* @param elementId the matched element ID (may be null)
* @return the new read-only context
*/
public ContextRef openReadOnly(String username, String branch, String commitId,
String searchQuery, String elementId) {
return openReadOnly(username, branch, commitId, WorkspaceContext.SHARED, searchQuery, elementId);
}
/**
* Open a read-only context for a specific branch and commit.
*
* @param username the user performing the navigation
* @param branch the branch to view
* @param commitId the commit SHA (null for HEAD)
* @param ctx the workspace context for repository resolution
* @param searchQuery the search query that led here (may be null)
* @param elementId the matched element ID (may be null)
* @return the new read-only context
*/
public ContextRef openReadOnly(String username, String branch, String commitId,
WorkspaceContext ctx, String searchQuery, String elementId) {
UserWorkspaceState state = resolveState(username);
String resolvedCommit = resolveCommit(branch, commitId, ctx);
ContextRef previous = state.getCurrentContext();
ContextRef newCtx = new ContextRef(
UUID.randomUUID().toString(),
branch,
resolvedCommit,
Instant.now(),
ContextMode.READ_ONLY,
previous.contextId(),
previous.branch(),
previous.commitId(),
searchQuery,
elementId,
false
);
recordNavigation(state, previous.contextId(), newCtx.contextId(),
searchQuery != null ? NavigationReason.SEARCH_OPEN : NavigationReason.MANUAL_SWITCH);
state.setCurrentContext(newCtx);
log.info("User '{}' opened read-only context: branch='{}', commit='{}'",
username, branch, abbreviateSha(resolvedCommit));
return newCtx;
}
/**
* Switch the working context to a different branch/commit.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #switchContext(String, String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param username the user performing the switch
* @param branch the branch to switch to
* @param commitId the commit SHA (null for HEAD)
* @return the new context
*/
public ContextRef switchContext(String username, String branch, String commitId) {
return switchContext(username, branch, commitId, WorkspaceContext.SHARED);
}
/**
* Switch the working context to a different branch/commit.
*
* @param username the user performing the switch
* @param branch the branch to switch to
* @param commitId the commit SHA (null for HEAD)
* @param ctx the workspace context for repository resolution
* @return the new context
*/
public ContextRef switchContext(String username, String branch, String commitId,
WorkspaceContext ctx) {
UserWorkspaceState state = resolveState(username);
String resolvedCommit = resolveCommit(branch, commitId, ctx);
ContextRef previous = state.getCurrentContext();
ContextRef newCtx = new ContextRef(
UUID.randomUUID().toString(),
branch,
resolvedCommit,
Instant.now(),
ContextMode.EDITABLE,
previous.contextId(),
previous.branch(),
previous.commitId(),
null,
null,
false
);
recordNavigation(state, previous.contextId(), newCtx.contextId(), NavigationReason.MANUAL_SWITCH);
state.setCurrentContext(newCtx);
log.info("User '{}' switched context: branch='{}', commit='{}'",
username, branch, abbreviateSha(resolvedCommit));
return newCtx;
}
/**
* Return to the origin context for a user.
*
* @param username the user performing the navigation
* @return the origin context, or the current context if no origin exists
*/
public ContextRef returnToOrigin(String username) {
UserWorkspaceState state = resolveState(username);
ContextRef current = state.getCurrentContext();
if (current.originContextId() == null) {
log.debug("User '{}': no origin context to return to", username);
return current;
}
ContextRef origin = new ContextRef(
UUID.randomUUID().toString(),
current.originBranch() != null ? current.originBranch() : "draft",
current.originCommitId(),
Instant.now(),
ContextMode.EDITABLE,
null,
null,
null,
null,
null,
false
);
recordNavigation(state, current.contextId(), origin.contextId(), NavigationReason.RETURN);
state.setCurrentContext(origin);
log.info("User '{}' returned to origin: branch='{}'", username, origin.branch());
return origin;
}
/**
* Go one step back in navigation history for a user.
*
* @param username the user performing the navigation
* @return the previous context, or the current context if history is empty
*/
public ContextRef back(String username) {
UserWorkspaceState state = resolveState(username);
if (state.isHistoryEmpty()) {
log.debug("User '{}': navigation history is empty — cannot go back", username);
return state.getCurrentContext();
}
ContextHistoryEntry lastEntry = state.peekLastHistory();
if (lastEntry == null) {
return state.getCurrentContext();
}
ContextRef current = state.getCurrentContext();
String targetBranch = current.originBranch() != null
? current.originBranch() : "draft";
String targetCommit = current.originCommitId();
ContextRef backCtx = new ContextRef(
UUID.randomUUID().toString(),
targetBranch,
targetCommit,
Instant.now(),
ContextMode.EDITABLE,
null,
null,
null,
null,
null,
false
);
state.pollLastHistory();
state.setCurrentContext(backCtx);
log.info("User '{}' navigated back to: branch='{}'", username, targetBranch);
return backCtx;
}
/**
* Get the full navigation history for a user.
*
* @param username the user whose history to retrieve
* @return list of navigation entries (newest last)
*/
public List<ContextHistoryEntry> getHistory(String username) {
return resolveState(username).getHistory();
}
/**
* Create a new branch variant from a user's current context.
*
* @param username the user creating the variant
* @param variantName the name for the new branch
* @return the new context on the variant branch
* @throws IOException if the branch creation fails
*/
public ContextRef createVariantFromCurrent(String username, String variantName) throws IOException {
return createVariantFromCurrent(username, variantName, WorkspaceContext.SHARED);
}
/**
* Create a new branch variant from a user's current context.
*
* @param username the user creating the variant
* @param variantName the name for the new branch
* @param ctx the workspace context for repository resolution
* @return the new context on the variant branch
* @throws IOException if the branch creation fails
*/
public ContextRef createVariantFromCurrent(String username, String variantName,
WorkspaceContext ctx) throws IOException {
UserWorkspaceState state = resolveState(username);
ContextRef current = state.getCurrentContext();
String sourceBranch = current.branch();
String newCommitId = resolveRepository(ctx).createBranch(variantName, sourceBranch);
ContextRef variantCtx = new ContextRef(
UUID.randomUUID().toString(),
variantName,
newCommitId,
Instant.now(),
ContextMode.EDITABLE,
current.contextId(),
current.branch(),
current.commitId(),
null,
null,
false
);
recordNavigation(state, current.contextId(), variantCtx.contextId(), NavigationReason.VARIANT_CREATED);
state.setCurrentContext(variantCtx);
log.info("User '{}' created variant '{}' from branch '{}'", username, variantName, sourceBranch);
return variantCtx;
}
/**
* Check if a user's current context is read-only.
*
* @param username the user to check
* @return true if edits should be blocked
*/
public boolean isReadOnly(String username) {
ContextRef ctx = resolveState(username).getCurrentContext();
return ctx.mode() == ContextMode.READ_ONLY
|| ctx.mode() == ContextMode.TEMPORARY;
}
// ── Internal helpers ────────────────────────────────────────────
private UserWorkspaceState resolveState(String username) {
return workspaceManager.getOrCreateWorkspace(username);
}
private String resolveCommit(String branch, String commitId, WorkspaceContext ctx) {
if (commitId != null) {
return commitId;
}
try {
return resolveRepository(ctx).getHeadCommit(branch);
} catch (IOException e) {
log.warn("Could not resolve HEAD for branch '{}': {}", branch, e.getMessage());
return null;
}
}
private void recordNavigation(UserWorkspaceState state, String fromId,
String toId, NavigationReason reason) {
state.addHistoryEntry(new ContextHistoryEntry(fromId, toId, reason, Instant.now()));
}
private String abbreviateSha(String sha) {
if (sha == null) return "null";
return sha.substring(0, Math.min(7, sha.length()));
}
}